Compare commits

...

241 Commits

Author SHA1 Message Date
Amir Raminfar a3310a71f6 chore: release v6.6.2 2024-05-13 08:59:08 -07:00
Amir Raminfar dafc9f674e fix: fixes broken homepage with stats (#2956) 2024-05-13 15:58:52 +00:00
Amir Raminfar 16b1aad93f chore: release v6.6.1 2024-05-13 08:31:41 -07:00
Amir Raminfar b82a0e2fef chore: updates go modules (#2955) 2024-05-13 15:28:29 +00:00
Amir Raminfar 9510a1f79e chore: updates some modules 2024-05-13 08:21:44 -07:00
Marcin Wołosz 8a0166d37d feat: polish language added (#2954) 2024-05-13 07:40:59 -07:00
Amir Raminfar 0a2248dfe3 feat: makes host cards more responsive on mobile (#2949) 2024-05-12 19:45:54 -07:00
Amir Raminfar a712c8fa34 chore: spacing 2024-05-12 13:29:30 -07:00
Amir Raminfar 4b3d2b3224 feat: uses expoentital average for host (#2951) 2024-05-12 20:24:04 +00:00
Fabeuss 2864bafa14 fix: updates fr.yml (#2950) 2024-05-12 13:21:02 -07:00
Freddy Grande 8433f89bd2 feat: Adding debug log when host-specific certificate path not found (#2948) 2024-05-12 05:56:52 -07:00
Freddy Grande bc1a5fbc8b docs: Updated documentation for Remote Host Setup (#2947) 2024-05-12 05:55:16 -07:00
Amir Raminfar 4b046596b7 fix: brings back labels CPU and bases CPU on one core. See #2944 (#2945) 2024-05-12 01:46:28 +00:00
Amir Raminfar e09aeb421b chore: reverts back vue-tsc (#2946) 2024-05-12 01:41:47 +00:00
renovate[bot] 5b12a7ba24 fix(deps): update all non-major dependencies (#2943)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-10 21:43:21 +00:00
Amir Raminfar dc2d892b89 chore: release v6.6.0 2024-05-10 14:37:25 -07:00
Amir Raminfar a991e66436 feat: adds generate subcommand to auto generate users.yml (#2939) 2024-05-10 14:35:49 -07:00
Amir Raminfar 44f68cc482 feat: Shows hosts in cards with their respective stats and updates the container table to filter by host 🥳 (#2932) 2024-05-10 21:35:17 +00:00
renovate[bot] dcb5eb4ccc chore(deps): update all non-major dependencies (#2942)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-09 15:28:33 +00:00
Amir Raminfar 8a2640cd6b chore: send server id by loading system info (#2936) 2024-05-08 01:18:51 +00:00
renovate[bot] d37c1395cb fix(deps): update all non-major dependencies (#2937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-08 00:13:33 +00:00
Amir Raminfar de23166a22 chore: uses tailwind daisyui progress bar instead 2024-05-07 08:49:40 -07:00
renovate[bot] fdffba179d fix(deps): update dependency unplugin-vue-macros to ^2.9.2 (#2933)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-06 01:02:58 +00:00
renovate[bot] b008354f02 fix(deps): update all non-major dependencies (#2930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-04 16:37:14 +00:00
Amir Raminfar deaeff73cb feat: sorts homepage every 10 seconds (#2929) 2024-05-02 17:20:13 +00:00
renovate[bot] 88f0a227e7 fix(deps): update dependency vite to v5.2.11 (#2928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-02 14:47:11 +00:00
renovate[bot] dc993aa816 chore(deps): update all non-major dependencies (#2926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-01 21:14:22 +00:00
Amir Raminfar cc8e715cc5 feat: downloads visible streams only instead of all (#2924) 2024-04-30 16:40:27 +00:00
renovate[bot] 448341c343 fix(deps): update all non-major dependencies (#2920)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-29 21:27:36 +00:00
renovate[bot] 53b91e662b fix(deps): update all non-major dependencies (#2919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-28 01:42:13 +00:00
Amir Raminfar 011f55c55b chore: release v6.5.2 2024-04-26 14:37:32 -07:00
Amir Raminfar d7079c36e4 chore: logs swarm mode to beacon (#2918) 2024-04-26 21:30:32 +00:00
Amir Raminfar 7fb4dcce77 docs: updates homepage sub heading 2024-04-25 08:54:16 -07:00
Amir Raminfar 0699680c2f docs: updates homepage 2024-04-25 08:46:38 -07:00
renovate[bot] e91d643968 chore(deps): update dependency vitest to ^1.5.2 (#2917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-25 15:35:28 +00:00
renovate[bot] d0b522116d chore(deps): update all non-major dependencies (#2916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-25 01:16:29 +00:00
renovate[bot] 56ec36e10c chore(deps): update all non-major dependencies (#2914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-23 17:53:48 +00:00
Amir Raminfar 588cd604ce feat: Add Turkish language support (#2912)
Co-authored-by: hasanbeder <hasanbeder@yahoo.com>
2024-04-22 08:03:01 -07:00
renovate[bot] c49b761f1f fix(deps): update all non-major dependencies (#2911)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-22 14:56:45 +00:00
renovate[bot] 22d8e86061 chore(deps): update pnpm to v9.0.5 (#2910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-22 00:34:26 +00:00
renovate[bot] a6ca9635d6 chore(deps): update all non-major dependencies (#2908)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-20 20:34:33 +00:00
renovate[bot] b5b8650ce6 fix(deps): update module github.com/docker/docker to v26.0.2+incompatible [security] (#2906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-19 00:51:07 +00:00
renovate[bot] 4d75978133 chore(deps): update all non-major dependencies (#2905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-18 15:02:46 +00:00
renovate[bot] 534f21fa06 chore(deps): update all non-major dependencies (#2903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 20:21:18 +00:00
Amir Raminfar 2f160a9d8b chore: release v6.5.1 2024-04-16 12:56:14 -07:00
Amir Raminfar f4dd11d136 feat: only groups containers when there is more than one (#2902) 2024-04-16 19:55:59 +00:00
Amir Raminfar 81705c8a47 feat: implement sticky collapsed and expanded groups on side menu (#2901) 2024-04-16 08:44:26 -07:00
renovate[bot] 2882961288 chore(deps): update all non-major dependencies (#2899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 15:17:17 +00:00
renovate[bot] bac8737161 chore(deps): update pnpm to v9 (#2900) 2024-04-16 08:12:36 -07:00
renovate[bot] 4d9cc362df chore(deps): update all non-major dependencies (#2898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 01:33:22 +00:00
Amir Raminfar 885db043ad chore: release v6.5.0 2024-04-14 14:22:48 -07:00
renovate[bot] f804e475d1 chore(deps): update all non-major dependencies (#2896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-14 21:19:46 +00:00
Amir Raminfar e4e6903ef3 fix: removes toSorted() for older browsers that do not support it (#2895) 2024-04-14 14:06:11 -07:00
Amir Raminfar ec03e93298 feat: groups containers by stack or compose when possible (#2893) 2024-04-14 14:05:27 -07:00
renovate[bot] 56aa440c92 chore(deps): update all non-major dependencies (#2892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-12 16:49:19 +00:00
Amir Raminfar 27989fca52 chore: release v6.4.6 2024-04-11 09:37:26 -07:00
Amir Raminfar 02c834359b fix: uses noindex instead of robots.txt (#2890) 2024-04-11 16:37:12 +00:00
Amir Raminfar 857896e448 chore: release v6.4.5 2024-04-11 09:26:29 -07:00
Amir Raminfar b781570811 fix: removes any configuration from index.html when users are not authenticated (#2889) 2024-04-11 09:03:37 -07:00
renovate[bot] ac04e4c149 chore(deps): update all non-major dependencies (#2888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-11 15:27:50 +00:00
renovate[bot] 89d761d389 chore(deps): update all non-major dependencies (#2886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-10 15:17:17 +00:00
Amir Raminfar fdb0984e3a docs: updates analytics docs 2024-04-10 08:09:24 -07:00
Amir Raminfar 9e99e043cd chore: release v6.4.4 2024-04-09 14:00:14 -07:00
Amir Raminfar 9aaae8ee91 fix: fixes actions with new routes. fixes #2883 (#2884) 2024-04-09 20:59:38 +00:00
Amir Raminfar 5110456537 chore: updates packages (#2882) 2024-04-09 10:59:46 -07:00
Amir Raminfar 6fdd5b788f docs: adds filter documentation 2024-04-09 09:18:25 -07:00
Amir Raminfar 2b6fb8556f chore: release v6.4.3 2024-04-08 14:03:18 -07:00
Amir Raminfar af7ed8b424 chore: fixes interval healthcheck (#2881) 2024-04-08 14:02:59 -07:00
renovate[bot] c63dc9dc13 chore(deps): update docker/setup-buildx-action action to v3.3.0 (#2880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-08 19:04:00 +00:00
Amir Raminfar 83f488ecef fix: tries to reconnect of remote host disconnects (#2876) 2024-04-08 11:59:03 -07:00
renovate[bot] 3d2036c97a chore(deps): update all non-major dependencies (#2878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-08 00:23:36 +00:00
renovate[bot] f3d49607a3 chore(deps): update all non-major dependencies (#2875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-05 18:06:51 +00:00
Amir Raminfar 9b641a99da fix: fixes background color with ansi colors. fixes #2873 (#2874) 2024-04-05 10:56:05 -07:00
renovate[bot] d09e179445 fix(deps): update all non-major dependencies (#2872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-04 15:48:53 +00:00
renovate[bot] daffeb17f8 chore(deps): update all non-major dependencies (#2871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 20:30:30 +00:00
Amir Raminfar 3c7df537ec chore: changes APIs to be more restful and prepare for services (#2869) 2024-04-02 10:01:02 -07:00
renovate[bot] 376318bbbf chore(deps): update all non-major dependencies (#2868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-02 16:26:38 +00:00
renovate[bot] c448636641 chore(deps): update actions/configure-pages action to v5 (#2864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-30 07:39:13 -07:00
renovate[bot] 30323bf3af chore(deps): update dependency @types/node to ^20.12.2 (#2863)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-30 14:35:02 +00:00
renovate[bot] 296ed7945b chore(deps): update all non-major dependencies (#2860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-29 15:00:33 +00:00
Amir Raminfar f343e12979 chore: enables static checker (#2859) 2024-03-28 10:35:10 -07:00
renovate[bot] b3b01d96a8 chore(deps): update all non-major dependencies (#2858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-28 15:24:17 +00:00
renovate[bot] 783b7f0307 fix(deps): update all non-major dependencies (#2857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-27 18:46:25 +00:00
Amir Raminfar cc26b1be81 chore: release v6.4.2 2024-03-26 09:07:37 -07:00
renovate[bot] 263ad3f235 chore(deps): update all non-major dependencies (#2855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-26 15:50:54 +00:00
Amir Raminfar 9c1b967502 feat: improves icons placement for expanding json (#2854) 2024-03-26 15:46:05 +00:00
renovate[bot] 21c775d40b fix(deps): update dependency @intlify/unplugin-vue-i18n to v4 (#2853)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-26 08:43:20 -07:00
renovate[bot] 943d0a87a1 chore(deps): update all non-major dependencies (#2852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-26 14:35:49 +00:00
Amir Raminfar a08e85cf7b chore: cleans up tests (#2850) 2024-03-25 15:33:16 -07:00
Amir Raminfar 3a2546f919 feat: hides per page dropdown when total number of containers is less than a page (#2849) 2024-03-25 08:09:10 -07:00
renovate[bot] d55e5f1c53 fix(deps): update dependency unplugin-vue-macros to ^2.7.13 (#2848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-25 03:50:06 +00:00
Amir Raminfar 0639320dca chore: updates total test for collector (#2847) 2024-03-25 01:45:09 +00:00
renovate[bot] 293f6a075b fix(deps): update all non-major dependencies (#2846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-25 01:32:55 +00:00
Amir Raminfar ef0938d6b8 chore: adds tests (#2845) 2024-03-23 21:32:17 -07:00
renovate[bot] 192d3783af fix(deps): update all non-major dependencies (#2843)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-22 19:11:29 +00:00
renovate[bot] 2d7c806390 fix(deps): update module github.com/docker/docker to v26 (#2841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-21 09:16:51 -07:00
renovate[bot] e643099f34 fix(deps): update all non-major dependencies (#2840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-21 16:14:48 +00:00
Amir Raminfar 821c3e90f2 chore: release v6.4.1 2024-03-21 09:09:25 -07:00
Amir Raminfar b72ee29aa3 fix: this fixes a critical bug where multiple stat collectors were competing and never stopping. see #2838 (#2839) 2024-03-21 16:09:01 +00:00
Amir Raminfar 83ef59cd88 chore: release v6.4.0 2024-03-20 11:37:27 -07:00
Amir Raminfar 0ca6624238 feat: deactivates collection of stats after 6 hours of idle (#2830) 2024-03-20 10:53:52 -07:00
renovate[bot] f9aabf138c fix(deps): update all non-major dependencies (#2837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-20 16:12:39 +00:00
renovate[bot] 7ed2c50ba7 chore(deps): update all non-major dependencies (#2836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 22:14:23 +00:00
Amir Raminfar 993b3fd2f6 fix: fixes parsing of logfmt logs with a regex. fixes #2834 (#2835) 2024-03-19 14:36:39 -07:00
Amir Raminfar 9bf6cd8821 chore: code fix 2024-03-19 14:27:57 -07:00
renovate[bot] cb99bcdc09 fix(deps): update dependency date-fns to ^3.6.0 (#2832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-18 03:52:02 +00:00
renovate[bot] a8e4267c67 chore(deps): update all non-major dependencies (#2831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-18 00:57:39 +00:00
renovate[bot] c0a80c5591 chore(deps): update all non-major dependencies (#2827)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-15 16:42:44 +00:00
Amir Raminfar 7f38bdaf4b chore: code clean up 2024-03-14 12:22:32 -07:00
Amir Raminfar c2fce52e1c perf: adds caching and brotli (#2826) 2024-03-14 12:11:36 -07:00
Amir Raminfar 09a9886423 perf: makes messages for copy and paste lazy for minor perf improvement 2024-03-14 08:29:16 -07:00
Amir Raminfar e7cabc8857 chore: release v6.3.1 2024-03-13 08:37:55 -07:00
Amir Raminfar 7e3d501f0f fix: fixes global regex with g flag. fixes #2822 (#2825) 2024-03-13 15:36:55 +00:00
Amir Raminfar c2dbcd546c chore: release v6.3.0 2024-03-12 12:20:41 -07:00
Amir Raminfar ec2be9c0f2 feat: brings colors back and fixes #2639 (#2823) 2024-03-12 19:15:18 +00:00
Amir Raminfar 32ed13d7d7 fix: uses data attributes for auto theme and removes postcss-mixins (#2820) 2024-03-11 16:57:47 +00:00
renovate[bot] f5b4cfd55a fix(deps): update all non-major dependencies (#2819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-11 16:44:58 +00:00
renovate[bot] 4c07a7210b fix(deps): update all non-major dependencies (#2818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-11 01:05:59 +00:00
Fabeuss 34d8ef6913 feat: Adds french (#2815) 2024-03-10 06:32:41 -07:00
Amir Raminfar 7fbac3b118 chore: fix types 2024-03-08 11:41:46 -08:00
renovate[bot] 4e59cf3937 fix(deps): update dependency @intlify/unplugin-vue-i18n to v3 (#2813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-07 15:01:40 -08:00
renovate[bot] e02378f93f chore(deps): update all non-major dependencies (#2812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-07 22:04:10 +00:00
Nikolaos Pothitos 32b8b02607 docs: add missing base slash and remove non-existent nginx configuration (#2809) 2024-03-05 16:10:01 -08:00
renovate[bot] 721e8e9e36 chore(deps): update all non-major dependencies (#2808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 22:11:40 +00:00
Amir Raminfar 8055512bc4 chore: release v6.2.7 2024-03-05 10:46:33 -08:00
Amir Raminfar 61f26b7bc3 fix: redirects login page to previous page after successful login. see #2806 (#2807) 2024-03-05 18:42:46 +00:00
renovate[bot] e6528a7fc1 fix(deps): update dependency postcss-mixins to v10 (#2805)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 10:37:46 -08:00
renovate[bot] 378de1a12b fix(deps): update all non-major dependencies (#2804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-04 23:23:22 +00:00
renovate[bot] c803fe3119 chore(deps): update dependency vue-tsc to v2 (#2803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-04 08:43:58 -08:00
renovate[bot] 0e1541b78d chore(deps): update all non-major dependencies (#2802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-03 22:07:55 +00:00
Amir Raminfar 1d14c6c6e7 chore: release v6.2.6 2024-03-01 13:09:02 -08:00
Amir Raminfar b78b70b233 feat: adds hideMenu as a query parameter (#2796) 2024-02-29 08:11:02 -08:00
renovate[bot] f9465a2d66 chore(deps): update all non-major dependencies (#2797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-28 18:30:49 +00:00
renovate[bot] a5da85d603 chore(deps): update all non-major dependencies to ^3.4.20 (#2795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-26 03:40:30 +00:00
renovate[bot] 2140fe3d37 fix(deps): update module github.com/puzpuzpuz/xsync/v3 to v3.1.0 (#2794)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-25 21:24:11 +00:00
renovate[bot] ff2e766324 chore(deps): update all non-major dependencies (#2791)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-24 16:21:17 +00:00
Amir Raminfar 336e8acd0b fix: improves fuzzy by using key down instead of up (#2790) 2024-02-23 21:09:14 +00:00
renovate[bot] a6c2c7dbbc fix(deps): update all non-major dependencies (#2789)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 17:38:43 +00:00
Amir Raminfar 8d844b4302 chore: release v6.2.5 2024-02-20 09:15:38 -08:00
Amir Raminfar ac66969126 chore: fixes types 2024-02-20 09:15:17 -08:00
renovate[bot] b896e16941 fix(deps): update all non-major dependencies (#2788)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 16:57:21 +00:00
Amir Raminfar 5b1b2463b0 fix: fixes missing nano seconds for parsing between logs. see #2783 (#2785) 2024-02-20 08:50:00 -08:00
Amir Raminfar 07d0c7fac5 fix: fixes changing items per page. fixes #2784 (#2786) 2024-02-19 23:12:16 +00:00
renovate[bot] d2d573c399 fix(deps): update all non-major dependencies (#2782)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-19 01:57:52 +00:00
renovate[bot] 54ad9d094b chore(deps): update all non-major dependencies (#2781)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-16 21:51:56 +00:00
renovate[bot] afb9bc971c fix(deps): update all non-major dependencies (#2778)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-14 16:42:38 +00:00
renovate[bot] 3e0d010390 chore(deps): update all non-major dependencies (#2777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-13 16:39:46 +00:00
renovate[bot] 76e5b960aa fix(deps): update all non-major dependencies (#2776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-12 20:16:36 +00:00
Amir Raminfar 152bdb8c8f feat: adds italian locale (#2775) 2024-02-12 15:53:06 +00:00
Amir Raminfar 77e49d132f chore: removes unused packages (#2772) 2024-02-11 13:43:55 -08:00
Amir Raminfar 3fe087935c chore: release v6.2.4 2024-02-10 09:46:49 -08:00
renovate[bot] b47cf7940a chore(deps): update all non-major dependencies (#2771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-10 17:16:41 +00:00
Amir Raminfar 420b398e50 chore: uses 1.22 for testing (#2770) 2024-02-10 17:13:19 +00:00
Ivan 91d79c99ea feat: add Content-Encoding for downloading logs (#2769) 2024-02-10 06:02:10 -08:00
Amir Raminfar 28c2cdc571 chore: release v6.2.3 2024-02-09 11:13:20 -08:00
Amir Raminfar d67f2da1ba fix: uses github.com/goccy/go-json for less CPU (#2766) 2024-02-09 11:11:20 -08:00
Ahmet a99672a5da chore: adjust supported env variables in readme (#2768) 2024-02-08 23:18:09 +00:00
renovate[bot] eefa472516 fix(deps): update all non-major dependencies (#2764)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-08 18:58:12 +00:00
Amir Raminfar 11cde3d5fc chore: fixes improvements with go1.22 2024-02-07 12:33:28 -08:00
Amir Raminfar 935d76e539 chore: release v6.2.2 2024-02-07 11:20:47 -08:00
Amir Raminfar 2497d62fe2 chore: fixes tests 2024-02-07 11:13:33 -08:00
Amir Raminfar 7103cc3c5c fix: fixes initital load not being ready yet (#2763) 2024-02-07 18:53:19 +00:00
Amir Raminfar cd877c910c fix: fixes a possible race condition (#2762) 2024-02-07 17:48:48 +00:00
renovate[bot] 946f59e603 fix(deps): update all non-major dependencies (#2758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-07 16:29:05 +00:00
Amir Raminfar a03e5d10aa chore: disables static check (#2760) 2024-02-07 16:24:02 +00:00
renovate[bot] 183326db52 fix(deps): update all non-major dependencies (#2754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-05 01:57:41 +00:00
Amir Raminfar 0ca3058f7b chore: release v6.2.1 2024-02-04 09:03:07 -08:00
Amir Raminfar 9c7ccc0a59 fix: stops streaming of stats for new containers when stopped (#2753) 2024-02-04 16:28:39 +00:00
Amir Raminfar 85d989450d chore: uses xsync package for maps (#2752) 2024-02-04 16:25:05 +00:00
Amir Raminfar 3f81f355bb chore: release v6.2.0 2024-02-03 14:08:26 -08:00
Amir Raminfar 25cb3c4c53 chore: adds tests for store (#2751) 2024-02-03 09:06:05 -08:00
Amir Raminfar 1d4c162fba Revert "chore: updates tests"
This reverts commit 19a83b7b62.
2024-02-02 16:48:58 -08:00
Amir Raminfar 19a83b7b62 chore: updates tests 2024-02-02 16:20:08 -08:00
Amir Raminfar 454156ae2c fix: fixes a regression bug in map. fixes #2749 (#2750) 2024-02-02 15:54:00 -08:00
EDIflyer 2d8b263df4 fix: reword message on similar container switch countdown banner (#2748) 2024-02-02 14:42:39 -08:00
Amir Raminfar fc8b67a902 fix: fixes race issues (#2747) 2024-02-02 13:56:48 -08:00
Amir Raminfar b8869784d4 fix: fixes a potential deadlock when reading stats (#2745) 2024-02-02 09:57:06 -08:00
Amir Raminfar 099c053341 feat: sorts most recent containers on top for fuzzy search (#2746) 2024-02-02 09:41:26 -08:00
Amir Raminfar bd86b2289b feat: updates labels between running and all containers (#2742) 2024-02-01 22:07:16 +00:00
Amir Raminfar 8677a34087 feat: collects all stats like cpu and mem in background for up to 5 minutes (#2740) 2024-02-01 12:43:44 -08:00
renovate[bot] 2c398cc227 chore(deps): update all non-major dependencies (#2741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-01 20:29:39 +00:00
Amir Raminfar bb0ca30b81 feat: search textbox is now draggable (#2737) 2024-01-30 17:22:58 +00:00
renovate[bot] f9eb8186d1 chore(deps): update dependency @types/node to ^20.11.10 (#2735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-29 00:16:05 +00:00
Amir Raminfar d767be3757 chore: updates go modules (#2734) 2024-01-28 19:06:49 +00:00
renovate[bot] 86f9839211 chore(deps): update all non-major dependencies (#2733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-27 18:52:22 +00:00
Amir Raminfar 67f25b7bb0 chore: renames cts to cjs (#2732) 2024-01-25 10:03:46 -08:00
Amir Raminfar 025ce28134 chore: release v6.1.1 2024-01-24 10:27:28 -08:00
renovate[bot] 25fced7bb9 chore(deps): update all non-major dependencies (#2731)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-24 17:52:53 +00:00
Amir Raminfar ef636c86fc fix: removes deprecated code (#2730) 2024-01-24 17:48:29 +00:00
Amir Raminfar 808abd57bb fix: improves logfmt (#2729) 2024-01-23 13:08:03 -08:00
Amir Raminfar d07dc2e983 fix: fixes cpu on popup being too long (#2728) 2024-01-23 09:01:15 -08:00
renovate[bot] 099f778ca2 fix(deps): update all non-major dependencies (#2724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-22 16:32:02 +00:00
renovate[bot] 9a760a2738 chore(deps): update dependency jsdom to v24 (#2723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-22 08:23:33 -08:00
renovate[bot] b53c6c8275 fix(deps): update dependency @iconify-json/carbon to ^1.1.28 (#2722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-22 00:46:05 +00:00
renovate[bot] 8dbe9a5774 chore(deps): update all non-major dependencies (#2720)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-20 22:33:32 +00:00
Amir Raminfar 5d28917d11 fix(deps): update module github.com/docker/docker to v25 (#2719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-19 14:43:18 -08:00
renovate[bot] 586d717aff fix(deps): update all non-major dependencies (#2717)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-19 16:32:24 +00:00
Amir Raminfar 8aba7debe8 chore: release v6.1.0 2024-01-18 09:11:38 -08:00
renovate[bot] c6647133dd chore(deps): update all non-major dependencies to ^3.4.15 (#2713)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-18 17:08:47 +00:00
Amir Raminfar 82765f4b68 feat: adds support for compact mode. fixes #2710 (#2712) 2024-01-18 17:04:58 +00:00
Amir Raminfar 0c33d0f921 feat: defaults locale to auto (#2711) 2024-01-18 08:56:38 -08:00
Amir Raminfar 89fe9e6b91 feat: adds a dropdown to switch language locale (#2708) 2024-01-18 01:22:06 +00:00
Amir Raminfar ef359d339c feat: adds support for LogFmt. fixes #2695 (#2705) 2024-01-17 14:01:40 -08:00
Amir Raminfar 9d2a8af435 feat: makes button for page size smaller (#2707) 2024-01-17 14:01:18 -08:00
renovate[bot] 6f96303f1d chore(deps): update all non-major dependencies (#2709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-17 21:54:37 +00:00
Amir Raminfar 48d7109079 Revert "feat: adds support for LogFmt. fixes #2695"
This reverts commit ccd7f0dd86.
2024-01-16 13:39:04 -08:00
Amir Raminfar ccd7f0dd86 feat: adds support for LogFmt. fixes #2695 2024-01-16 13:30:57 -08:00
Amir Raminfar 390d1a3062 chore: release v6.0.8 2024-01-15 10:39:24 -08:00
Amir Raminfar c6c2c13404 feat: adds option to home page to show more than default containers per page (#2703) 2024-01-15 18:38:21 +00:00
renovate[bot] f80dd56bdf chore(deps): update all non-major dependencies (#2702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-15 02:22:22 +00:00
Amir Raminfar 89babeab9a chore: moves warn to debug for releases 2024-01-12 11:37:38 -08:00
Feriman 9abab4ce77 read only docker.sock (#2698) 2024-01-12 11:35:49 -08:00
Feriman 19c3daf2f7 read only docker.sock (#2699) 2024-01-12 11:35:31 -08:00
AmadeusGraves 04fb4f3fa4 Update locale es.yml (#2690) 2024-01-11 05:29:01 -08:00
Amir Raminfar c5680a9b3f chore: makes faked data better. (#2689) 2024-01-10 13:42:23 -08:00
Amir Raminfar 5cd6fc1243 chore: release v6.0.7 2024-01-10 11:26:24 -08:00
Amir Raminfar 1e2ce61991 fix: fixes title bar alignment to base of text (#2688) 2024-01-10 11:23:53 -08:00
renovate[bot] b5e78ae979 fix(deps): update all non-major dependencies (#2687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 19:15:56 +00:00
Amir Raminfar f7a36483f9 feat: moves search box higher. fixes #2679 (#2684) 2024-01-10 11:10:24 -08:00
Amir Raminfar cc6ae1d63c fix: fixes 24 hour style being broken on chrome. fixes #2680 (#2685) 2024-01-10 11:10:10 -08:00
Amir Raminfar 9cb973f30e fix: moves cache for releases to API. fixes #2681 (#2686) 2024-01-10 11:07:09 -08:00
renovate[bot] c98bcd3fcf chore(deps): update all non-major dependencies (#2677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 00:18:05 +00:00
Amir Raminfar b0cc0a0377 chore: release v6.0.6 2024-01-09 14:09:34 -08:00
Amir Raminfar 3a705f2de0 fix: fixes title container stats in small screen (#2678) 2024-01-09 14:09:16 -08:00
Amir Raminfar e1d38a3582 fix: fixes stats on windows (#2675) 2024-01-09 13:03:30 -08:00
Amir Raminfar da24ca249a chore: enables container queries (#2676) 2024-01-09 11:49:56 -08:00
Amir Raminfar 6ca33b5a62 feat: alt-click on side menu opens a new panel (#2674) 2024-01-09 10:25:54 -08:00
Amir Raminfar 9a431dbcb0 fix: adds long message for preview (#2672) 2024-01-08 15:53:02 -08:00
Amir Raminfar 3b5370fbae chore: release v6.0.5 2024-01-08 14:40:09 -08:00
Amir Raminfar 8b183e98ff feat: allows for date format to be overriden with locale. fixes #2665 (#2671) 2024-01-08 14:36:08 -08:00
renovate[bot] 7038bb35b2 chore(deps): update all non-major dependencies (#2670)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-08 20:25:25 +00:00
Amir Raminfar 72a580574a feat: adds preview for settings (#2669) 2024-01-08 12:21:37 -08:00
renovate[bot] 27fe0663cf chore(deps): update all non-major dependencies (#2668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-08 01:20:43 +00:00
Amir Raminfar 00cb7a6018 chore: release v6.0.4 2024-01-07 14:21:56 -08:00
Amir Raminfar 89b763af59 feat: if password hash is != 64 then error is displayed. (#2667) 2024-01-07 22:17:05 +00:00
Amir Raminfar 71d76e9228 chore: release v6.0.3 2024-01-06 09:00:43 -08:00
renovate[bot] d8d7c9fd4f fix(deps): update all non-major dependencies (#2663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-06 16:32:46 +00:00
Amir Raminfar f87d3bbe3e chore: improves icon size (#2662) 2024-01-06 16:29:13 +00:00
renovate[bot] de88a25bcc chore(deps): update all non-major dependencies (#2661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-06 03:31:24 +00:00
Amir Raminfar fc93e2d33c chore: adds more tests (#2660) 2024-01-05 19:28:02 -08:00
Amir Raminfar c1eb699dc6 feat: moves settings to top of the home page (#2659) 2024-01-06 01:56:29 +00:00
135 changed files with 9203 additions and 5833 deletions
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.21.x
go-version: 1.22.x
check-latest: true
- name: Checkout code
uses: actions/checkout@v4
@@ -84,14 +84,14 @@ jobs:
with:
images: amir20/dozzle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.3.0
with:
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
+3 -3
View File
@@ -18,14 +18,14 @@ jobs:
with:
images: amir20/dozzle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.3.0
with:
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
+1 -3
View File
@@ -1,5 +1,3 @@
# Sample workflow for building and deploying a VitePress site to GitHub Pages
#
name: Deploy VitePress site to Pages
on:
@@ -36,7 +34,7 @@ jobs:
node-version: latest
cache: pnpm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v5
- name: Install dependencies
run: pnpm install
- name: Build with VitePress
+5 -1
View File
@@ -54,12 +54,16 @@ jobs:
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.21.x
go-version: 1.22.x
check-latest: true
- name: Checkout code
uses: actions/checkout@v4
- name: Run Go Tests with Coverage
run: make test SKIP_ASSET=1
- name: Stactic checker
uses: dominikh/staticcheck-action@v1.3.1
with:
install-go: false
int-test:
name: Integration Tests
timeout-minutes: 60
+1 -1
View File
@@ -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 -race main.go --level debug
+3 -3
View File
@@ -1,5 +1,5 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:21-alpine as node
FROM --platform=$BUILDPLATFORM node:21.7.3-alpine as node
RUN corepack enable
@@ -14,7 +14,7 @@ COPY package.json ./
RUN pnpm install --offline --ignore-scripts --no-optional
# Copy assets and translations to build
COPY .* *.config.ts *.config.js *.config.cts ./
COPY .* *.config.ts *.config.js *.config.cjs ./
COPY assets ./assets
COPY locales ./locales
COPY public ./public
@@ -22,7 +22,7 @@ COPY public ./public
# Build assets
RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.21.5-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.22.3-alpine AS builder
RUN apk add --no-cache ca-certificates && mkdir /dozzle
+1 -1
View File
@@ -15,7 +15,7 @@ fake_assets:
.PHONY: test
test: fake_assets
go test -cover ./...
go test -cover -race ./...
.PHONY: build
build: dist
+18 -16
View File
@@ -31,7 +31,7 @@ Dozzle is a small container (4 MB compressed). Pull the latest release with:
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:8080 amir20/dozzle:latest
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock:ro -p 8888:8080 amir20/dozzle:latest
Dozzle will be available at [http://localhost:8888/](http://localhost:8888/).
@@ -43,7 +43,7 @@ Here is the Docker Compose file:
container_name: dozzle
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- 8888:8080
@@ -76,7 +76,7 @@ If it's not enabled please follow [this tutorial](https://github.com/containers/
Once you have the podman remote socket you can run Dozzle on podman.
```
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8888:8080 amir20/dozzle:latest
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock:ro -d -p 8888:8080 amir20/dozzle:latest
```
## Security
@@ -93,19 +93,21 @@ 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` | `/` |
| `--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` | |
| Flag | Env Variable | Default |
| --------------------------- | -------------------------------- | -------------- |
| `--addr` | `DOZZLE_ADDR` | `:8080` |
| `--base` | `DOZZLE_BASE` | `/` |
| `--hostname` | `DOZZLE_HOSTNAME` | `""` |
| `--level` | `DOZZLE_LEVEL` | `info` |
| `--auth-provider` | `DOZZLE_AUTH_PROVIDER` | `none` |
| `--auth-header-user` | `DOZZLE_AUTH_HEADER_USER` | `Remote-User` |
| `--auth-header-email` | `DOZZLE_AUTH_HEADER_EMAIL` | `Remote-Email` |
| `--auth-header-name` | `DOZZLE_AUTH_HEADER_NAME` | `Remote-Name` |
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | false |
| `--wait-for-docker-seconds` | `DOZZLE_WAIT_FOR_DOCKER_SECONDS` | 0 |
| `--filter` | `DOZZLE_FILTER` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
## Support
+5 -9
View File
@@ -3,6 +3,7 @@
</template>
<script lang="ts" setup>
const mode = useColorMode();
watchEffect(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars");
@@ -10,16 +11,11 @@ watchEffect(() => {
document.documentElement.classList.remove("has-custom-scrollbars");
}
switch (lightTheme.value) {
case "dark":
document.documentElement.setAttribute("data-theme", "dark");
break;
case "light":
document.documentElement.setAttribute("data-theme", "light");
break;
default:
document.documentElement.removeAttribute("data-theme");
let theme = lightTheme.value;
if (theme === "auto") {
theme = mode.value;
}
document.documentElement.setAttribute("data-theme", theme);
});
</script>
<style lang="postcss">
+17
View File
@@ -20,6 +20,7 @@ declare global {
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const automaticRedirect: typeof import('./stores/settings')['automaticRedirect']
const collapseNav: typeof import('./stores/settings')['collapseNav']
const compact: typeof import('./stores/settings')['compact']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
@@ -40,6 +41,7 @@ declare global {
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const dateLocale: typeof import('./stores/settings')['dateLocale']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
@@ -68,6 +70,7 @@ declare global {
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const lightTheme: typeof import('./stores/settings')['lightTheme']
const locale: typeof import('./stores/settings')['locale']
const logSearchContext: typeof import('./composable/logSearchContext')['logSearchContext']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
@@ -227,6 +230,7 @@ declare global {
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useHead: typeof import('@vueuse/head')['useHead']
const useHosts: typeof import('./stores/hosts')['useHosts']
const useI18n: typeof import('vue-i18n')['useI18n']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
@@ -287,6 +291,7 @@ declare global {
const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
@@ -357,6 +362,7 @@ declare global {
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly $$: UnwrapRef<typeof import('vue/macros')['$$']>
readonly $: UnwrapRef<typeof import('vue/macros')['$']>
@@ -373,6 +379,7 @@ declare module 'vue' {
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
@@ -393,6 +400,7 @@ declare module 'vue' {
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly dateLocale: UnwrapRef<typeof import('./stores/settings')['dateLocale']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
@@ -421,6 +429,7 @@ declare module 'vue' {
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -579,6 +588,7 @@ declare module 'vue' {
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
@@ -639,6 +649,7 @@ declare module 'vue' {
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
@@ -702,6 +713,7 @@ declare module 'vue' {
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly $$: UnwrapRef<typeof import('vue/macros')['$$']>
readonly $: UnwrapRef<typeof import('vue/macros')['$']>
@@ -718,6 +730,7 @@ declare module '@vue/runtime-core' {
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
@@ -738,6 +751,7 @@ declare module '@vue/runtime-core' {
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly dateLocale: UnwrapRef<typeof import('./stores/settings')['dateLocale']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
@@ -766,6 +780,7 @@ declare module '@vue/runtime-core' {
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -924,6 +939,7 @@ declare module '@vue/runtime-core' {
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
@@ -984,6 +1000,7 @@ declare module '@vue/runtime-core' {
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
+8 -1
View File
@@ -1,10 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BarChart: typeof import('./components/BarChart.vue')['default']
@@ -26,6 +26,7 @@ declare module 'vue' {
'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']
ContainerLogViewer: typeof import('./components/LogViewer/ContainerLogViewer.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']
@@ -37,9 +38,11 @@ declare module 'vue' {
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
HostList: typeof import('./components/HostList.vue')['default']
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
Links: typeof import('./components/Links.vue')['default']
LogActionsToolbar: typeof import('./components/LogViewer/LogActionsToolbar.vue')['default']
LogContainer: typeof import('./components/LogViewer/LogContainer.vue')['default']
@@ -50,6 +53,8 @@ declare module 'vue' {
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default']
'MaterialSymbols:collapseAllRounded': typeof import('~icons/material-symbols/collapse-all-rounded')['default']
'MaterialSymbols:expandAllRounded': typeof import('~icons/material-symbols/expand-all-rounded')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:check': typeof import('~icons/mdi/check')['default']
@@ -69,6 +74,8 @@ declare module 'vue' {
'Ph:command': typeof import('~icons/ph/command')['default']
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
'Ph:memory': typeof import('~icons/ph/memory')['default']
Popup: typeof import('./components/Popup.vue')['default']
Releases: typeof import('./components/Releases.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
+80 -24
View File
@@ -1,5 +1,49 @@
!
<template>
<div class="flex flex-row">
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
<input
type="radio"
name="host"
role="tab"
class="tab !rounded"
aria-label="Show All"
v-model="selectedHost"
:value="null"
/>
<input
type="radio"
name="host"
role="tab"
class="tab !rounded"
:aria-label="host.name"
v-for="host in hosts"
:value="host.id"
:key="host.id"
v-model="selectedHost"
/>
</div>
<dropdown-menu
class="btn-sm"
v-model="selectedHost"
:options="[
{ label: 'Show All', value: null },
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
]"
v-else
/>
</div>
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
{{ $t("label.per-page") }}
<dropdown-menu
class="dropdown-left btn-xs md:btn-sm"
v-model="perPage"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
/>
</div>
</div>
<table class="table table-lg bg-base">
<thead>
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
@@ -32,28 +76,34 @@
<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>
<div class="flex flex-row items-center gap-1">
<progress
class="progress progress-primary"
:value="Math.min(container.movingAverage.cpu, 100)"
:max="100"
></progress>
<span class="text-sm">{{ container.movingAverage.cpu.toFixed(0) }}%</span>
</div>
</td>
<td v-if="isVisible('mem')">
<bar-chart :value="container.movingAverage.memory / 100">
{{ (container.movingAverage.memory / 100).toLocaleString(undefined, { style: "percent" }) }}
</bar-chart>
<div class="flex flex-row items-center gap-1">
<progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress>
<span class="text-sm">{{ container.movingAverage.memory.toFixed(0) }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
<div class="p-4 text-center">
<nav class="join" v-if="isPaginated">
<button
<input
class="btn btn-square join-item"
type="radio"
v-model="currentPage"
:aria-label="`${i}`"
:value="i"
v-for="i in totalPages"
class="btn join-item"
:class="{ 'btn-primary': i === currentPage }"
@click="currentPage = i"
>
{{ i }}
</button>
/>
</nav>
</div>
</template>
@@ -62,6 +112,9 @@
import { Container } from "@/models/Container";
import { toRefs } from "@vueuse/core";
const { hosts } = useHosts();
const selectedHost = ref(null);
const fields = {
name: {
label: "label.container-name",
@@ -95,32 +148,35 @@ const fields = {
},
};
const { containers, perPage = 15 } = defineProps<{
const { containers } = defineProps<{
containers: Container[];
perPage?: number;
}>();
type keys = keyof typeof fields;
const perPage = useStorage("DOZZLE_TABLE_PAGE_SIZE", 15);
const pageSizes = [15, 30, 50, 100];
const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CONTAINERS_SORT", {
column: "created",
direction: -1,
});
const { column: sortField, direction } = toRefs(storage);
const counter = useInterval(10000);
const filteredContainers = computed(() =>
containers.filter((c) => selectedHost.value === null || c.host === selectedHost.value),
);
const sortedContainers = computedWithControl(
() => [containers.length, sortField.value, direction.value],
() => {
return containers.sort((a, b) => {
return fields[sortField.value].sortFunc(a, b);
});
},
() => [filteredContainers.value.length, sortField.value, direction.value, counter.value],
() => filteredContainers.value.sort((a, b) => fields[sortField.value].sortFunc(a, b)),
);
const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage));
const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage.value));
const isPaginated = computed(() => totalPages.value > 1);
const currentPage = ref(1);
watch(perPage, () => (currentPage.value = 1));
const paginated = computed(() => {
const start = (currentPage.value - 1) * perPage;
const end = start + perPage;
const start = (currentPage.value - 1) * perPage.value;
const end = start + perPage.value;
return sortedContainers.value.slice(start, end);
});
+23 -4
View File
@@ -4,12 +4,16 @@ import { mount } from "@vue/test-utils";
import FuzzySearchModal from "./FuzzySearchModal.vue";
import { Container } from "@/models/Container";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { router } from "@/modules/router";
// @ts-ignore
import EventSource, { sources } from "eventsourcemock";
vi.mock("vue-router");
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
@@ -27,9 +31,9 @@ function createFuzzySearchModal() {
initialState: {
container: {
containers: [
new Container("123", new Date(), "image", "test", "command", "host", {}, "status", "running"),
new Container("123", new Date(), "image", "foo bar", "command", "host", {}, "status", "running"),
new Container("123", new Date(), "image", "baz", "command", "host", {}, "status", "exited"),
new Container("123", new Date(), "image", "test", "command", "host", {}, "status", "running", []),
new Container("345", new Date(), "image", "foo bar", "command", "host", {}, "status", "running", []),
new Container("567", new Date(), "image", "baz", "command", "host", {}, "status", "exited", []),
],
},
},
@@ -44,6 +48,14 @@ function createFuzzySearchModal() {
* @vitest-environment jsdom
*/
describe("<FuzzySearchModal />", () => {
vi.mocked(useRouter).mockReturnValue({
...router,
push: vi.fn(),
});
beforeEach(() => {
vi.mocked(useRouter().push).mockReset();
});
test("shows all", async () => {
const wrapper = createFuzzySearchModal();
expect(wrapper.findAll("li").length).toBe(3);
@@ -57,4 +69,11 @@ describe("<FuzzySearchModal />", () => {
`"<span data-v-dc2e8c61="" data-name=""><mark>foo</mark> bar</span>"`,
);
});
test("choose baz", async () => {
const wrapper = createFuzzySearchModal();
await wrapper.find("input").setValue("baz");
await wrapper.find("input").trigger("keydown.enter");
expect(useRouter().push).toHaveBeenCalledWith({ name: "container-id", params: { id: "567" } });
});
});
+15 -11
View File
@@ -4,12 +4,12 @@
<mdi:magnify class="flex size-8" />
<input
tabindex="0"
class="input input-ghost input-lg flex-1 px-1"
class="input input-lg input-ghost 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].item)"
@keyup.alt.enter="addColumn(data[selectedIndex])"
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
@keydown.enter.exact="selected(data[selectedIndex].item)"
@keydown.alt.enter="addColumn(data[selectedIndex])"
v-model="query"
:placeholder="$t('placeholder.search-containers')"
/>
@@ -91,18 +91,18 @@ const { results } = useFuse(query, list, {
});
const data = computed(() => {
return results.value
.toSorted((a, b) => {
return [...results.value]
.sort((a, b) => {
if (a.score === b.score) {
if (a.item.state === "running" && b.item.state !== "running") {
if (a.item.state === b.item.state) {
return b.item.created - a.item.created;
} else 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 a.score - b.score;
}
})
.slice(0, maxResults);
@@ -148,4 +148,8 @@ function matchedName({ item, matches = [] }: { item: { name: string }; matches?:
:deep(mark) {
@apply bg-transparent text-inherit underline underline-offset-2;
}
.menu a {
@apply transition-none duration-0;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<ul class="grid gap-4 md:grid-cols-[repeat(auto-fill,minmax(480px,1fr))]">
<li v-for="host in hosts" class="card bg-base-lighter">
<div class="card-body grid auto-cols-auto grid-flow-col justify-between gap-4">
<div class="overflow-hidden">
<div class="truncate text-xl font-semibold">{{ host.name }}</div>
<ul class="flex flex-row gap-2 text-sm md:gap-4">
<li><ph:cpu class="inline-block" /> {{ host.nCPU }} <span class="mobile-hidden">CPUs</span></li>
<li>
<ph:memory class="inline-block" /> {{ formatBytes(host.memTotal) }}
<span class="mobile-hidden">total</span>
</li>
</ul>
<div class="text-sm">
<octicon:container-24 class="inline-block" /> {{ $t("label.container", hostContainers[host.id]?.length) }}
</div>
</div>
<div class="flex flex-row gap-4 md:gap-8" v-if="weightedStats[host.id]">
<div
class="radial-progress text-sm text-primary [--size:4rem] [--thickness:0.25em] md:text-[1rem] md:[--size:5rem]"
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalCPU / (host.nCPU * 100)) * 100)}; `"
role="progressbar"
>
{{ weightedStats[host.id].weighted.totalCPU.toFixed(0) }}%
</div>
<div
class="radial-progress text-sm text-primary [--size:4rem] [--thickness:0.25em] md:text-[1rem] md:[--size:5rem]"
:style="`--value: ${(weightedStats[host.id].weighted.totalMem / host.memTotal) * 100};`"
role="progressbar"
>
{{ formatBytes(weightedStats[host.id].weighted.totalMem, 1) }}
</div>
</div>
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import { Container } from "@/models/Container";
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore) as unknown as {
containers: Ref<Container[]>;
};
const runningContainers = computed(() => containers.value.filter((container) => container.state === "running"));
const { hosts } = useHosts();
const hostContainers = computed(() => {
const results: Record<string, Container[]> = {};
for (const container of runningContainers.value) {
if (!results[container.host]) {
results[container.host] = [];
}
results[container.host].push(container);
}
return results;
});
type TotalStat = {
totalCPU: number;
totalMem: number;
};
const weightedStats: Record<string, { mostRecent: TotalStat; weighted: TotalStat }> = {};
const initWeightedStats = () => {
for (const [host, containers] of Object.entries(hostContainers.value)) {
const mostRecent = ref<TotalStat>({ totalCPU: 0, totalMem: 0 });
for (const container of containers) {
mostRecent.value.totalCPU += container.stat.cpu;
mostRecent.value.totalMem += container.stat.memoryUsage;
}
weightedStats[host] = reactive({ mostRecent, weighted: useExponentialMovingAverage(mostRecent) });
}
};
watchOnce(hostContainers, initWeightedStats);
initWeightedStats();
useIntervalFn(
() => {
for (const [host, containers] of Object.entries(hostContainers.value)) {
const stat = { totalCPU: 0, totalMem: 0 };
for (const container of containers) {
stat.totalCPU += container.stat.cpu;
stat.totalMem += container.stat.memoryUsage;
}
weightedStats[host].mostRecent = stat;
}
},
1000,
{ immediate: true },
);
</script>
<style scoped></style>
+9
View File
@@ -15,6 +15,15 @@
</template>
</dropdown>
<router-link
:to="{ name: 'settings' }"
:aria-label="$t('title.settings')"
data-testid="settings"
class="btn btn-circle btn-sm"
>
<mdi:cog class="size-6" />
</router-link>
<dropdown class="dropdown-end" v-if="config.user">
<template #trigger>
<img
+11 -16
View File
@@ -1,5 +1,10 @@
<template>
<div class="relative flex w-full gap-x-2">
<div class="group/item relative flex w-full gap-x-2" @click="expandToggle()">
<label class="swap swap-rotate invisible absolute -left-4 top-0.5 size-4 group-hover/item:visible">
<input type="checkbox" v-model="expanded" @click="expandToggle()" />
<material-symbols:expand-all-rounded class="swap-off text-secondary" />
<material-symbols:collapse-all-rounded class="swap-on text-secondary" />
</label>
<div v-if="showStd">
<log-std :std="logEntry.std"></log-std>
</div>
@@ -10,8 +15,8 @@
<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">
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }">
<li v-for="(value, name) in validValues" :key="name">
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null">&lt;null&gt;</span>
<template v-else-if="Array.isArray(value)">
<span class="font-bold" v-html="markSearch(JSON.stringify(value))"> </span>
@@ -24,7 +29,7 @@
</div>
<log-message-actions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
:message="JSON.stringify(logEntry.message)"
:message="() => JSON.stringify(logEntry.message)"
:log-entry="logEntry"
/>
</div>
@@ -52,18 +57,8 @@ const validValues = computed(() => {
}
.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";
}
li {
@apply inline-flex;
}
}
</style>
@@ -0,0 +1,39 @@
<template>
<log-viewer :messages="filtered" :last-selected-item="lastSelectedItem" :visible-keys="visibleKeys" />
</template>
<script lang="ts" setup>
import { useRouteHash } from "@vueuse/router";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
const props = defineProps<{
messages: LogEntry<string | JSONObject>[];
}>();
const { container } = useContainerContext();
const visibleKeys = persistentVisibleKeys(container);
const { filteredPayload } = useVisibleFilter(visibleKeys);
const { filteredMessages } = useSearchFilter();
const { messages } = toRefs(props);
const visible = filteredPayload(messages);
const filtered = filteredMessages(visible);
const { lastSelectedItem } = useLogSearchContext() as {
lastSelectedItem: Ref<LogEntry<string | JSONObject> | undefined>;
};
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"></style>
@@ -7,7 +7,7 @@
</div>
<div>
<span class="font-light capitalize"> Load </span>
<span class="font-semibold"> {{ container.stat.cpu }}% </span>
<span class="font-semibold"> {{ container.stat.cpu.toFixed(2) }}% </span>
</div>
<div>
<span class="font-light capitalize"> MEM </span>
@@ -1,7 +1,7 @@
<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) + '%'" />
<stat-monitor :data="cpuData" label="load" :stat-value="Math.max(0, unref(container.stat).cpu).toFixed(2) + '%'" />
</div>
</template>
@@ -11,11 +11,11 @@ const { container } = useContainerContext();
const cpuData = computedWithControl(
() => container.value.stat,
() => {
const history = container.value.statHistory;
const history = container.value.statsHistory;
const points: Point<unknown>[] = history.map((stat, i) => ({
x: i,
y: Math.max(0, stat.snapshot.cpu),
value: Math.max(0, stat.snapshot.cpu) + "%",
y: Math.max(0, stat.cpu),
value: Math.max(0, stat.cpu).toFixed(2) + "%",
}));
return points;
},
@@ -24,11 +24,11 @@ const cpuData = computedWithControl(
const memoryData = computedWithControl(
() => container.value.stat,
() => {
const history = container.value.statHistory;
const history = container.value.statsHistory;
const points: Point<string>[] = history.map((stat, i) => ({
x: i,
y: stat.snapshot.memory,
value: formatBytes(stat.snapshot.memoryUsage),
y: stat.memory,
value: formatBytes(stat.memoryUsage),
}));
return points;
},
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
<label class="swap swap-rotate">
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
<label class="swap swap-rotate size-4">
<input type="checkbox" v-model="pinned" />
<carbon:star-filled class="swap-on text-secondary" />
<carbon:star class="swap-off" />
@@ -18,7 +18,9 @@
</div>
</div>
<container-health :health="container.health" v-if="container.health"></container-health>
<tag class="mobile-hidden font-mono" size="small">{{ container.image.replace(/@sha.*/, "") }}</tag>
<tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
{{ container.image.replace(/@sha.*/, "") }}
</tag>
</div>
</template>
@@ -39,15 +39,15 @@ const { container } = useContainerContext();
const nextContainer = computed(
() =>
containers.value
.filter(
[
...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],
),
].sort((a, b) => +a.created - +b.created)[0],
);
function redirectNow() {
+7 -7
View File
@@ -1,6 +1,6 @@
<template>
<ul v-if="expanded" ref="root" class="ml-8">
<li v-for="(value, name) in fields">
<li v-for="(value, name) in fields" :key="name">
<template v-if="isObject(value)">
<span class="text-light">{{ name }}=</span>
<field-list
@@ -11,9 +11,9 @@
></field-list>
</template>
<template v-else-if="Array.isArray(value)">
<a @click="toggleField(name)" class="link-primary cursor-pointer">
{{ hasField(name) ? "remove" : "add" }}&nbsp;</a
>
<a @click.stop="toggleField(name)" class="link-primary mr-2 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>
@@ -21,9 +21,9 @@
]
</template>
<template v-else>
<a @click="toggleField(name)" class="link-primary cursor-pointer">
{{ hasField(name) ? "remove" : "add" }}&nbsp;</a
>
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">
{{ hasField(name) ? "remove" : "add" }}
</a>
<span class="text-light">{{ name }}=</span><span class="font-bold" v-html="value"></span>
</template>
</li>
@@ -12,9 +12,7 @@
</a>
</li>
<li>
<a :href="`${base}/api/logs/download/${container.host}/${container.id}`" download>
<octicon:download-24 /> {{ $t("toolbar.download") }}
</a>
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li>
<a @click.prevent="showSearch = true">
@@ -104,7 +102,7 @@
<script lang="ts" setup>
const { showSearch } = useSearchFilter();
const { base, enableActions } = config;
const { enableActions } = config;
const clear = defineEmit();
@@ -113,6 +111,18 @@ const { container, streamConfig } = useContainerContext();
// container context is provided in the parent component: <LogContainer>
const { actionStates, start, stop, restart } = useContainerActions();
const downloadParams = computed(() =>
Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
);
const downloadUrl = computed(() =>
withBase(
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
),
);
const disableRestart = computed(() => {
return actionStates.stop || actionStates.start || actionStates.restart;
});
@@ -10,7 +10,7 @@ import { computed, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router";
import LogEventSource from "./LogEventSource.vue";
import LogViewer from "./LogViewer.vue";
import ContainerLogViewer from "./ContainerLogViewer.vue";
vi.mock("@/stores/config", () => ({
__esModule: true,
@@ -71,7 +71,7 @@ describe("<LogEventSource />", () => {
global: {
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
components: {
LogViewer,
ContainerLogViewer,
},
provide: {
[containerContext as symbol]: {
@@ -83,14 +83,14 @@ describe("<LogEventSource />", () => {
},
slots: {
default: `
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
<template #scoped="params"><container-log-viewer :messages="params.messages" /></template>
`,
},
props: {},
});
}
const sourceUrl = "/api/logs/stream/localhost/abc?stdout=1&stderr=1";
const sourceUrl = "/api/hosts/localhost/containers/abc/logs/stream?stdout=1&stderr=1";
test("renders correctly", async () => {
const wrapper = createLogEventSource();
@@ -2,7 +2,7 @@
<div class="flex gap-2">
<div
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
v-if="isSupported && message.trim() != ''"
v-if="isSupported"
:title="t('log_actions.copy_log')"
>
<span
@@ -32,7 +32,7 @@
import { LogEntry, JSONObject } from "@/models/LogEntry";
const { message, logEntry } = defineProps<{
message: string;
message: () => string;
logEntry: LogEntry<string | JSONObject>;
}>();
@@ -44,7 +44,7 @@ const { isSearching } = useSearchFilter();
const { handleJumpLineSelected } = useLogSearchContext();
async function copyLogMessageToClipBoard() {
await copy(message);
await copy(message());
if (copied.value) {
showToast(
+11 -27
View File
@@ -1,7 +1,7 @@
<template>
<ul class="events group py-4" :class="{ 'disable-wrap': !softWrap, [size]: true }">
<ul class="events group py-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
<li
v-for="item in filtered"
v-for="item in messages"
:key="item.id"
:data-key="item.id"
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
@@ -14,36 +14,14 @@
<script lang="ts" setup>
import { toRaw } from "vue";
import { useRouteHash } from "@vueuse/router";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
const props = defineProps<{
defineProps<{
messages: LogEntry<string | JSONObject>[];
visibleKeys: string[][];
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
}>();
const { container } = useContainerContext();
const visibleKeys = persistentVisibleKeys(container);
const { filteredPayload } = useVisibleFilter(visibleKeys);
const { filteredMessages } = useSearchFilter();
const { messages } = toRefs(props);
const visible = filteredPayload(messages);
const filtered = filteredMessages(visible);
const { lastSelectedItem } = useLogSearchContext();
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 {
@@ -79,5 +57,11 @@ watch(
&.large {
@apply text-lg;
}
&.compact {
> li {
@apply py-0;
}
}
}
</style>
@@ -1,6 +1,6 @@
<template>
<log-event-source ref="source" #default="{ messages }" @loading-more="loadingMore($event)">
<log-viewer :messages="messages"></log-viewer>
<container-log-viewer :messages="messages" />
</log-event-source>
</template>
+11 -2
View File
@@ -5,11 +5,11 @@
<log-level class="flex" :level="logEntry.level" :position="logEntry.position" />
<div
class="whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap"
v-html="markSearch(logEntry.message)"
v-html="colorize(logEntry.message)"
></div>
<log-message-actions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
:message="decodeXML(logEntry.message)"
:message="() => decodeXML(stripAnsi(logEntry.message))"
:log-entry="logEntry"
/>
</div>
@@ -17,10 +17,19 @@
<script lang="ts" setup>
import { SimpleLogEntry } from "@/models/LogEntry";
import { decodeXML } from "entities";
import AnsiConvertor from "ansi-to-html";
import stripAnsi from "strip-ansi";
const ansiConvertor = new AnsiConvertor({
escapeXML: false,
fg: "var(--base-content-color)",
bg: "var(--base-color)",
});
defineProps<{
logEntry: SimpleLogEntry;
}>();
const { markSearch } = useSearchFilter();
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
</script>
+2 -2
View File
@@ -1,9 +1,9 @@
<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">
<div class="hidden overflow-hidden rounded-sm border border-primary px-px pb-px pt-1 md:flex">
<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="inline-flex gap-1 rounded bg-base p-px text-xs md:absolute md:-left-0.5 md:-top-2">
<div class="font-light uppercase">{{ label }}</div>
<div class="select-none font-bold">
{{ mouseOver ? selectedPoint?.value ?? selectedPoint?.y ?? statValue : statValue }}
@@ -12,7 +12,7 @@ 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 x = d3.scaleLinear().range([0, width]);
const y = d3.scaleLinear().range([height, 0]);
const selectedPoint = defineEmit<[value: Point<unknown>]>();
+7 -6
View File
@@ -4,7 +4,7 @@
<transition name="fade">
<div
v-show="show && (delayedShow || glopbalShow)"
class="fixed z-50 rounded border border-secondary/50 bg-base-lighter p-4 shadow"
class="fixed z-50 rounded border border-base-content/20 bg-base-lighter p-4 shadow"
ref="content"
>
<slot name="content"></slot>
@@ -20,18 +20,19 @@ let glopbalShow = globalShowPopup();
let show = ref(glopbalShow.value);
let delayedShow = refDebounced(show, 1000);
let content: HTMLElement | null = $ref(null);
let content = ref<HTMLElement>();
const onMouseEnter = (e: Event) => {
show.value = true;
glopbalShow.value = true;
if (e.target && content && e.target instanceof Element) {
if (content.value && e.target instanceof HTMLElement) {
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`;
content.value.style.left = `${x}px`;
content.value.style.top = `${y}px`;
}
};
@@ -40,7 +41,7 @@ const onMouseLeave = () => {
glopbalShow.value = false;
};
const el = useCurrentElement();
const el: Ref<HTMLElement> = useCurrentElement();
useEventListener(() => el.value?.nextElementSibling, "mouseenter", onMouseEnter);
useEventListener(() => el.value?.nextElementSibling, "mouseleave", onMouseLeave);
</script>
+10 -1
View File
@@ -1,6 +1,12 @@
<template>
<transition name="slide">
<div class="fixed right-px top-12 z-10 flex items-center p-4" v-show="showSearch" v-if="search">
<div
class="fixed z-10 flex w-full justify-end p-2"
v-show="showSearch"
v-if="search"
ref="container"
:style="style"
>
<div class="input input-primary flex h-auto items-center !shadow-lg">
<mdi:magnify />
<input
@@ -19,8 +25,11 @@
<script lang="ts" setup>
const input = ref<HTMLInputElement>();
const container = ref<HTMLDivElement>();
const { searchFilter, showSearch, resetSearch } = useSearchFilter();
const { style } = useDraggable(container);
onKeyStroke("f", (e) => {
if (e.ctrlKey || e.metaKey) {
showSearch.value = true;
+109 -88
View File
@@ -8,50 +8,55 @@
</li>
</ul>
</div>
<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)">
<li v-for="host in hosts" :key="host.id">
<a @click.prevent="setHost(host.id)" :class="{ 'pointer-events-none text-base-content/50': !host.available }">
<ph:computer-tower />
{{ host.name }}
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</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>
<container-health :health="item.health"></container-health>
<span
class="pin"
@click.stop.prevent="store.appendActiveContainer(item)"
v-show="!activeContainersById[item.id]"
:title="$t('tooltip.pin-column')"
>
<cil:columns />
</span>
</router-link>
<template #content>
<container-popup :container="item"></container-popup>
</template>
</popup>
<ul class="containers menu p-0 [&_li.menu-title]:px-0" v-else>
<li v-for="{ label, containers, icon } in menuItems" :key="label">
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
<summary class="font-light text-base-content/80">
<component :is="icon" />
{{ label.startsWith("label.") ? $t(label) : label }}
</summary>
<ul>
<li v-for="item in containers" :class="item.state" :key="item.id">
<popup>
<router-link
:to="{ name: 'container-id', params: { id: item.id } }"
active-class="active-primary"
@click.alt.stop.prevent="store.appendActiveContainer(item)"
:title="item.name"
>
<div class="truncate">
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">{{ item.swarmId }}</span>
</div>
<container-health :health="item.health"></container-health>
<span
class="pin"
@click.stop.prevent="store.appendActiveContainer(item)"
v-show="!activeContainersById[item.id]"
:title="$t('tooltip.pin-column')"
>
<cil:columns />
</span>
</router-link>
<template #content>
<container-popup :container="item"></container-popup>
</template>
</popup>
</li>
</ul>
</details>
</li>
</transition-group>
</ul>
</transition>
</div>
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
@@ -64,70 +69,86 @@
import { Container } from "@/models/Container";
import { sessionHost } from "@/composable/storage";
const { t } = useI18n();
// @ts-ignore
import Pin from "~icons/ph/map-pin-simple";
// @ts-ignore
import Stack from "~icons/ph/stack";
// @ts-ignore
import Containers from "~icons/octicon/container-24";
const store = useContainerStore();
const { activeContainers, visibleContainers, ready } = storeToRefs(store);
const { hosts } = useHosts();
function setHost(host: string | null) {
sessionHost.value = host;
}
const setHost = (host: string | null) => (sessionHost.value = host);
const debouncedIds = debouncedRef(pinnedContainers, 200);
const collapsedGroups = useProfileStorage("collapsedGroups", new Set<string>());
const updateCollapsedGroups = (event: Event, label: string) => {
const details = event.target as HTMLDetailsElement;
if (details.open) {
collapsedGroups.value.delete(label);
} else {
collapsedGroups.value.add(label);
}
};
const debouncedPinnedContainers = 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);
}
}),
visibleContainers.value.filter((c) => c.host === sessionHost.value).sort(sorter),
);
const groupedContainers = computed(() =>
sortedContainers.value.reduce(
(acc, item) => {
if (debouncedIds.value.has(item.name)) {
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 sorter = (a: Container, b: Container) => {
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 menuItems = computed(() => {
if (groupedContainers.value.pinned.length > 0) {
return [pinnedLabel, ...groupedContainers.value.pinned, allLabel, ...groupedContainers.value.unpinned];
} else {
return [allLabel, ...groupedContainers.value.unpinned];
}
});
const namespaced: Record<string, Container[]> = {};
const pinned = [];
const singular = [];
const hosts = computed(() =>
config.hosts.reduce(
(acc, item) => {
acc[item.id] = item;
return acc;
},
{} as Record<string, { name: string; id: string }>,
),
);
for (const item of sortedContainers.value) {
const namespace = item.labels["com.docker.stack.namespace"] ?? item.labels["com.docker.compose.project"];
if (debouncedPinnedContainers.value.has(item.name)) {
pinned.push(item);
} else if (namespace) {
namespaced[namespace] ||= [];
namespaced[namespace].push(item);
} else {
singular.push(item);
}
}
const items = [];
if (pinned.length) {
items.push({ label: "label.pinned", containers: pinned, icon: Pin });
}
for (const [label, containers] of Object.entries(namespaced).sort(([a], [b]) => a.localeCompare(b))) {
if (containers.length > 1) {
items.push({ label, containers, icon: Stack });
} else {
singular.push(containers[0]);
}
}
singular.sort(sorter);
if (singular.length) {
items.push({
label: showAllContainers.value ? "label.all-containers" : "label.running-containers",
containers: singular,
icon: Containers,
});
}
return items;
});
const activeContainersById = computed(() =>
activeContainers.value.reduce(
+5 -14
View File
@@ -12,25 +12,16 @@
</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:cog />
</router-link>
</div>
<a
<button
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')"
data-testid="search"
>
<mdi:magnify />
Search
<key-shortcut char="k"></key-shortcut>
</a>
{{ $t("placeholder.search") }}
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
</button>
<side-menu class="mt-4"></side-menu>
</aside>
+23 -6
View File
@@ -10,13 +10,30 @@ 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(
const dateOverride = computed(() => (dateLocale.value === "auto" ? undefined : dateLocale.value));
const dateFormatter = computed(
() => new Intl.DateTimeFormat(dateOverride.value, { day: "2-digit", month: "2-digit", year: "numeric" }),
);
const hourCycle = computed(() => {
switch (hourStyle.value) {
case "auto":
return undefined;
case "12":
return "h12";
case "24":
return "h23";
}
});
const timeFormatter = computed(
() =>
new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: use12Hour }),
new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: hourCycle.value,
}),
);
const dateStr = $computed(() => dateFormatter.format(props.date));
const timeStr = $computed(() => timeFormatter.format(props.date));
const dateStr = computed(() => dateFormatter.value.format(props.date));
const timeStr = computed(() => timeFormatter.value.format(props.date));
</script>
+1 -1
View File
@@ -5,7 +5,7 @@
</label>
<div
tabindex="0"
class="min-w-52 dropdown-content rounded-box z-50 mt-1 border border-base-content/20 bg-base p-2 shadow"
class="dropdown-content z-50 mt-1 min-w-52 rounded-box border border-base-content/20 bg-base p-2 shadow"
>
<slot name="content"></slot>
</div>
+11 -15
View File
@@ -1,9 +1,9 @@
<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>
<slot name="trigger"> {{ label }} <carbon:caret-down /></slot>
</summary>
<ul class="menu dropdown-content rounded-box z-50 mt-1 w-52 border border-base-content/20 bg-base p-2 shadow">
<ul class="menu dropdown-content z-50 mt-1 w-52 rounded-box border border-base-content/20 bg-base p-2 shadow">
<slot>
<li v-for="item in options">
<a @click="modelValue = item.value">
@@ -17,27 +17,23 @@
</details>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="T">
import { vOnClickOutside } from "@vueuse/components";
type DropdownItem = {
label: string;
value: string;
value: T;
};
const { options = [], defaultLabel = "" } = defineProps<{ options?: DropdownItem[]; defaultLabel?: string }>();
const { modelValue } = defineModels<{
modelValue: string;
modelValue: T;
}>();
const values = computed(() =>
options.reduce(
(acc, curr) => {
acc[curr.value] = curr.label;
return acc;
},
{} as Record<string, string>,
),
);
const { options = [], defaultLabel = "" } = defineProps<{
options?: DropdownItem[];
defaultLabel?: string;
}>();
const label = computed(() => options.find((item) => item.value === modelValue.value)?.label ?? defaultLabel);
const details = ref<HTMLElement | null>(null);
const close = () => details.value?.removeAttribute("open");
watch(modelValue, () => close());
+10
View File
@@ -0,0 +1,10 @@
<template>
<label class="label cursor-pointer gap-4">
<div class="label-text"><slot name="label" /></div>
<slot name="input" />
</label>
</template>
<script setup lang="ts"></script>
<style scoped></style>
-3
View File
@@ -29,9 +29,6 @@
class="btn-sm"
v-if="config.hosts.length > 1"
/>
<router-link :to="{ name: 'settings' }" class="btn btn-outline btn-sm">
<mdi:cog /> {{ $t("button.settings") }}
</router-link>
</div>
<ul class="menu">
+8 -4
View File
@@ -1,8 +1,12 @@
<template>
<label class="label inline-flex cursor-pointer gap-4 font-normal">
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" />
<slot />
</label>
<labeled-input>
<template #label>
<slot />
</template>
<template #input>
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" />
</template>
</labeled-input>
</template>
<script lang="ts" setup>
+1 -1
View File
@@ -10,7 +10,7 @@ export const useContainerActions = () => {
});
async function actionHandler(action: ContainerActions) {
const actionUrl = `/api/actions/${action}/${container.value.host}/${container.value.id}`;
const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`;
const errors = {
404: "container not found",
+6 -2
View File
@@ -85,7 +85,9 @@ export function useLogStream() {
console.debug(`Connecting to ${containerId} with params`, params);
es = new EventSource(
withBase(`/api/logs/stream/${container.value.host}/${containerId}?${new URLSearchParams(params).toString()}`),
withBase(
`/api/hosts/${container.value.host}/containers/${containerId}/logs/stream?${new URLSearchParams(params).toString()}`,
),
);
es.addEventListener("container-stopped", () => {
close();
@@ -118,7 +120,9 @@ export function useLogStream() {
const logs = await (
await fetch(
withBase(`/api/logs/${container.value.host}/${containerId}?${new URLSearchParams(params).toString()}`),
withBase(
`/api/hosts/${container.value.host}/containers/${containerId}/logs?${new URLSearchParams(params).toString()}`,
),
)
).text();
if (logs) {
+1 -1
View File
@@ -25,7 +25,7 @@ function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
export function useSearchFilter() {
const regex = $computed(() => {
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "i" : undefined);
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "ig" : "g");
});
function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) {
+5 -3
View File
@@ -1,8 +1,8 @@
<template>
<div>
<mobile-menu v-if="isMobile" @search="showFuzzySearch"></mobile-menu>
<mobile-menu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></mobile-menu>
<splitpanes @resized="onResized($event)">
<pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav">
<pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
<side-panel @search="showFuzzySearch"></side-panel>
</pane>
<pane min-size="10">
@@ -27,7 +27,7 @@
<label
class="btn btn-circle swap btn-neutral swap-rotate fixed -left-12 bottom-4 w-16 transition-all hover:-left-4"
:class="{ '!-left-6': collapseNav }"
v-if="!isMobile"
v-if="!isMobile && !forceMenuHidden"
>
<input type="checkbox" v-model="collapseNav" />
<mdi:chevron-right class="swap-on" />
@@ -79,6 +79,8 @@ const { toasts, removeToast } = useToast();
const modal = ref<HTMLDialogElement>();
const open = ref(false);
const searchParams = new URLSearchParams(window.location.search);
const forceMenuHidden = ref(searchParams.has("hideMenu"));
watch(open, () => {
if (open.value) {
+14 -30
View File
@@ -1,23 +1,5 @@
@import "splitpanes/dist/splitpanes.css";
@define-mixin light {
--base-lighter-color: 100% 0 0;
--base-color: 97% 0 0;
--base-darker-color: 90% 0 0;
--base-content-color: 33% 0 0;
--primary-color: 79.96% 0.143 176.65;
--secondary-color: 82.67% 0.15380739746702807 77.82;
}
@define-mixin dark {
--base-lighter-color: 25% 0 0;
--base-color: 18% 0 0;
--base-darker-color: 11% 0 0;
--base-content-color: 89.23% 0 0;
--primary-color: 70.96% 0.143 176.65;
--secondary-color: 91.36% 0.11098 90.1243;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -41,21 +23,23 @@
--n: var(--base-lighter-color);
}
html[data-theme="dark"] {
@mixin dark;
}
@media (prefers-color-scheme: dark) {
html {
@mixin dark;
}
--base-lighter-color: 25% 0 0;
--base-color: 18% 0 0;
--base-darker-color: 11% 0 0;
--base-content-color: 89.23% 0 0;
--primary-color: 70.96% 0.143 176.65;
--secondary-color: 91.36% 0.11098 90.1243;
}
html[data-theme="light"] {
@mixin light;
}
@media (prefers-color-scheme: light) {
html {
@mixin light;
}
--base-lighter-color: 100% 0 0;
--base-color: 97% 0 0;
--base-darker-color: 90% 0 0;
--base-content-color: 33% 0 0;
--primary-color: 79.96% 0.143 176.65;
--secondary-color: 82.67% 0.15380739746702807 77.82;
}
@media screen and (max-device-width: 480px) {
body {
-webkit-text-size-adjust: 100%;
+1 -1
View File
@@ -18,7 +18,7 @@ describe("Container", () => {
];
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");
const c = new Container("id", new Date(), "image", name!, "command", "host", {}, "status", "created", []);
expect(c.name).toBe(expectedName);
expect(c.swarmId).toBe(expectedSwarmId);
});
+7 -7
View File
@@ -1,6 +1,5 @@
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
import type { UseThrottledRefHistoryReturn } from "@vueuse/core";
import { useExponentialMovingAverage } from "@/utils";
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
import { Ref } from "vue";
type Stat = Omit<ContainerStat, "id">;
@@ -19,7 +18,7 @@ const hosts = computed(() =>
export class Container {
private _stat: Ref<Stat>;
private readonly throttledStatHistory: UseThrottledRefHistoryReturn<Stat, Stat>;
private readonly _statsHistory: Ref<Stat[]>;
public readonly swarmId: string | null = null;
public readonly isSwarm: boolean = false;
private readonly movingAverageStat: Ref<Stat>;
@@ -34,10 +33,11 @@ export class Container {
public readonly labels = {} as Record<string, string>,
public status: string,
public state: ContainerState,
stats: Stat[],
public health?: ContainerHealth,
) {
this._stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
this.throttledStatHistory = useThrottledRefHistory(this._stat, { capacity: 300, deep: true, throttle: 1000 });
this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat));
this._statsHistory = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
this.movingAverageStat = useExponentialMovingAverage(this._stat, 0.2);
const match = name.match(SWARM_ID_REGEX);
@@ -48,8 +48,8 @@ export class Container {
}
}
get statHistory() {
return unref(this.throttledStatHistory.history);
get statsHistory() {
return unref(this._statsHistory);
}
get movingAverage() {
+5 -4
View File
@@ -9,11 +9,12 @@ 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 type Level = "debug" | "info" | "warn" | "error" | "fatal" | "trace" | "unknown";
export interface LogEvent {
readonly m: string | JSONObject;
readonly ts: number;
readonly id: number;
readonly l: string;
readonly l: Level;
readonly p: Position;
readonly s: "stdout" | "stderr" | "unknown";
}
@@ -25,7 +26,7 @@ export abstract class LogEntry<T extends string | JSONObject> {
public readonly id: number,
public readonly date: Date,
public readonly std: Std,
public readonly level?: string,
public readonly level?: Level,
) {
this._message = message;
}
@@ -42,7 +43,7 @@ export class SimpleLogEntry extends LogEntry<string> {
message: string,
id: number,
date: Date,
public readonly level: string,
public readonly level: Level,
public readonly position: Position,
public readonly std: Std,
) {
@@ -60,7 +61,7 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
message: JSONObject,
id: number,
date: Date,
public readonly level: string,
public readonly level: Level,
public readonly std: Std,
visibleKeys?: Ref<string[][]>,
) {
+9 -5
View File
@@ -1,19 +1,23 @@
import { type App } from "vue";
import { createI18n } from "vue-i18n";
import { locale } from "@/stores/settings";
import messages from "@intlify/unplugin-vue-i18n/messages";
const locale = messages?.hasOwnProperty(navigator.language) ? navigator.language : navigator.language.slice(0, 2);
const defaultLocale = messages?.hasOwnProperty(navigator.language)
? navigator.language
: navigator.language.slice(0, 2);
const i18n = createI18n({
legacy: false,
locale: locale,
locale: locale.value || defaultLocale,
fallbackLocale: "en",
messages,
});
export const install = (app: App) => {
app.use(i18n);
};
watch(locale, (value) => {
i18n.global.locale.value = value || defaultLocale;
});
export const install = (app: App) => app.use(i18n);
export default i18n;
+6 -7
View File
@@ -3,13 +3,12 @@ import { createRouter, createWebHistory } from "vue-router";
import pages from "~pages";
import { setupLayouts } from "virtual:generated-layouts";
const routes = setupLayouts(pages);
export const router = createRouter({
history: createWebHistory(withBase("/")),
routes,
});
export const install = (app: App) => {
const routes = setupLayouts(pages);
const router = createRouter({
history: createWebHistory(withBase("/")),
routes,
});
app.use(router);
};
+5 -42
View File
@@ -1,25 +1,7 @@
<template>
<page-with-links class="gap-16">
<page-with-links>
<section>
<div class="stats grid bg-base-lighter shadow">
<div class="stat">
<div class="stat-value">{{ runningContainers.length }} / {{ containers.length }}</div>
<div class="stat-title">{{ $t("label.running") }} / {{ $t("label.total-containers") }}</div>
</div>
<div class="stat">
<div class="stat-value">{{ totalCpu }}%</div>
<div class="stat-title">{{ $t("label.total-cpu-usage") }}</div>
</div>
<div class="stat">
<div class="stat-value">{{ formatBytes(totalMem) }}</div>
<div class="stat-title">{{ $t("label.total-mem-usage") }}</div>
</div>
<div class="stat">
<div class="stat-value">{{ version }}</div>
<div class="stat-title">{{ $t("label.dozzle-version") }}</div>
</div>
</div>
<host-list />
</section>
<section>
@@ -32,37 +14,18 @@
import { Container } from "@/models/Container";
const { t } = useI18n();
const { version } = config;
const containerStore = useContainerStore();
const { containers, ready } = storeToRefs(containerStore) as unknown as {
containers: Ref<Container[]>;
ready: Ref<boolean>;
};
const mostRecentContainers = $computed(() => containers.value.toSorted((a, b) => +b.created - +a.created));
const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running"));
let totalCpu = $ref(0);
useIntervalFn(
() => {
totalCpu = runningContainers.reduce((acc, c) => acc + c.stat.cpu, 0);
},
1000,
{ immediate: true },
);
let totalMem = $ref(0);
useIntervalFn(
() => {
totalMem = runningContainers.reduce((acc, c) => acc + c.stat.memoryUsage, 0);
},
1000,
{ immediate: true },
);
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
watchEffect(() => {
if (ready.value) {
setTitle(t("title.dashboard", { count: runningContainers.length }));
setTitle(t("title.dashboard", { count: runningContainers.value.length }));
}
});
</script>
+8 -4
View File
@@ -17,7 +17,7 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
<span class="label-text">{{ $t("label.password") }}</span>
</label>
<input
class="input input-bordered"
@@ -47,17 +47,21 @@ let error = $ref(false);
let username = $ref("");
let password = $ref("");
let form: HTMLFormElement | undefined = $ref();
const params = new URLSearchParams(window.location.search);
async function onLogin() {
const url = config.authProvider === "simple" ? "/api/token" : "/api/validateCredentials";
const response = await fetch(withBase(url), {
const response = await fetch(withBase("/api/token"), {
body: new FormData(form),
method: "POST",
});
if (response.status == 200) {
error = false;
window.location.href = withBase("/");
if (params.has("redirectUrl")) {
window.location.href = withBase(params.get("redirectUrl")!);
} else {
window.location.href = withBase("/");
}
} else {
error = true;
}
+137 -47
View File
@@ -14,66 +14,114 @@
</div>
</section>
<section class="flex flex-col gap-4">
<section class="flex flex-col @container">
<div class="has-underline">
<h2>{{ $t("settings.display") }}</h2>
</div>
<div>
<toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </toggle>
</div>
<div>
<toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</toggle>
</div>
<div>
<toggle v-model="showStd">{{ $t("settings.show-std") }}</toggle>
</div>
<section class="grid-cols-2 gap-4 @3xl:grid">
<div class="flex flex-col gap-2 text-balance @3xl:pr-8">
<toggle v-model="compact"> {{ $t("settings.compact") }} </toggle>
<div>
<toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</toggle>
</div>
<toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </toggle>
<div class="flex items-center gap-6">
<dropdown-menu
v-model="hourStyle"
:options="[
{ label: 'Auto', value: 'auto' },
{ label: '12', value: '12' },
{ label: '24', value: '24' },
]"
<toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</toggle>
<toggle v-model="showStd">{{ $t("settings.show-std") }}</toggle>
<toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</toggle>
<labeled-input>
<template #label>
{{ $t("settings.locale") }}
</template>
<template #input>
<dropdown-menu
v-model="locale"
:options="[
{ label: 'Auto', value: '' },
...availableLocales.map((l) => ({ label: l.toLocaleUpperCase(), value: l })),
]"
/>
</template>
</labeled-input>
<labeled-input>
<template #label>
{{ $t("settings.datetime-format") }}
</template>
<template #input>
<div class="flex gap-2">
<dropdown-menu
v-model="dateLocale"
:options="[
{ label: 'Auto', value: 'auto' },
{ label: 'MM/DD/YYYY', value: 'en-US' },
{ label: 'DD/MM/YYYY', value: 'en-GB' },
{ label: 'DD.MM.YYYY', value: 'de-DE' },
{ label: 'YYYY-MM-DD', value: 'en-CA' },
]"
/>
<dropdown-menu
v-model="hourStyle"
:options="[
{ label: 'Auto', value: 'auto' },
{ label: '12', value: '12' },
{ label: '24', value: '24' },
]"
/>
</div>
</template>
</labeled-input>
<labeled-input>
<template #label>
{{ $t("settings.font-size") }}
</template>
<template #input>
<dropdown-menu
v-model="size"
:options="[
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
]"
/>
</template>
</labeled-input>
<labeled-input>
<template #label>
{{ $t("settings.color-scheme") }}
</template>
<template #input>
<dropdown-menu
v-model="lightTheme"
:options="[
{ label: 'Auto', value: 'auto' },
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' },
]"
/>
</template>
</labeled-input>
</div>
<log-viewer
:messages="fakeMessages"
:visible-keys="keys"
:last-selected-item="undefined"
class="hidden overflow-hidden rounded-lg border border-base-content/50 shadow @3xl:block"
/>
{{ $t("settings.12-24-format") }}
</div>
<div class="flex items-center gap-6">
<dropdown-menu
v-model="size"
:options="[
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
]"
/>
{{ $t("settings.font-size") }}
</div>
<div class="flex items-center gap-6">
<dropdown-menu
v-model="lightTheme"
:options="[
{ label: 'Auto', value: 'auto' },
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' },
]"
/>
{{ $t("settings.color-scheme") }}
</div>
</section>
</section>
<section class="flex flex-col gap-2">
<div class="has-underline">
<h2>{{ $t("settings.options") }}</h2>
</div>
<div>
<toggle v-model="search">
<div>{{ $t("settings.search") }} <key-shortcut char="f" class="align-top"></key-shortcut></div>
{{ $t("settings.search") }} <key-shortcut char="f" class="align-top"></key-shortcut>
</toggle>
</div>
@@ -89,9 +137,13 @@
</template>
<script lang="ts" setup>
import { ComplexLogEntry, SimpleLogEntry } from "@/models/LogEntry";
import {
automaticRedirect,
compact,
hourStyle,
dateLocale,
lightTheme,
search,
showAllContainers,
@@ -100,12 +152,50 @@ import {
size,
smallerScrollbars,
softWrap,
locale,
} from "@/stores/settings";
const { t } = useI18n();
const { t, availableLocales } = useI18n();
setTitle(t("title.settings"));
const { latest, hasUpdate } = useReleases();
const keys = ref<string[][]>([]);
const hoursAgo = (hours: number) => {
const date = new Date();
date.setHours(date.getHours() - hours);
return date;
};
const fakeMessages = [
new SimpleLogEntry("This is a preview of the logs", 1, hoursAgo(16), "info", undefined, "stdout"),
new SimpleLogEntry("A warning log looks like this", 2, hoursAgo(12), "warn", undefined, "stdout"),
new SimpleLogEntry("This is a multi line error message", 3, hoursAgo(7), "error", "start", "stderr"),
new SimpleLogEntry("with a second line", 4, hoursAgo(2), "error", "middle", "stderr"),
new SimpleLogEntry("and finally third line.", 5, new Date(), "error", "end", "stderr"),
new ComplexLogEntry(
{
message: "This is a complex log entry as json",
context: {
key: "value",
key2: "value2",
},
},
6,
new Date(),
"info",
"stdout",
keys,
),
new SimpleLogEntry(
"This is a very very long message which would wrap by default. Disabling soft wraps would disable this. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
7,
new Date(),
"debug",
undefined,
"stderr",
),
];
</script>
<style lang="postcss" scoped>
.has-underline {
+7 -1
View File
@@ -1,13 +1,16 @@
import { type Settings } from "@/stores/settings";
import { Host } from "@/stores/hosts";
const text = document.querySelector("script#config__json")?.textContent || "{}";
type HostWithoutAvailable = Omit<Host, "available">;
export interface Config {
version: string;
base: string;
maxLogs: number;
hostname: string;
hosts: { name: string; id: string }[];
hosts: HostWithoutAvailable[];
authProvider: "simple" | "none" | "forward-proxy";
enableActions: boolean;
user?: {
@@ -23,12 +26,15 @@ export interface Profile {
pinned?: Set<string>;
visibleKeys?: { [key: string]: string[][] };
releaseSeen?: string;
collapsedGroups?: Set<string>;
}
const pageConfig = JSON.parse(text);
const config: Config = {
maxLogs: 600,
version: "v0.0.0",
hosts: [],
...pageConfig,
};
+8 -1
View File
@@ -5,6 +5,7 @@ import { Container } from "@/models/Container";
import i18n from "@/modules/i18n";
const { showToast } = useToast();
const { markHostAvailable } = useHosts();
// @ts-ignore
const { t } = i18n.global;
@@ -64,10 +65,15 @@ export const useContainerStore = defineStore("container", () => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string };
const container = allContainersById.value[event.actorId];
if (container) {
container.state = "dead";
container.state = "exited";
}
});
es.addEventListener("host-unavailable", (e) => {
const hostId = (e as MessageEvent).data;
markHostAvailable(hostId, false);
});
es.addEventListener("container-health", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; health: ContainerHealth };
const container = allContainersById.value[event.actorId];
@@ -127,6 +133,7 @@ export const useContainerStore = defineStore("container", () => {
c.labels,
c.status,
c.state,
c.stats,
c.health,
);
}),
+27
View File
@@ -0,0 +1,27 @@
export type Host = {
name: string;
id: string;
nCPU: number;
memTotal: number;
available: boolean;
};
const hosts = computed(() =>
config.hosts.reduce(
(acc, item) => {
acc[item.id] = { ...item, available: true };
return acc;
},
{} as Record<string, Host>,
),
);
const markHostAvailable = (id: string, available: boolean) => {
hosts.value[id].available = available;
};
export function useHosts() {
return {
hosts,
markHostAvailable,
};
}
+9
View File
@@ -3,6 +3,7 @@ import { toRefs } from "@vueuse/core";
export type Settings = {
search: boolean;
size: "small" | "medium" | "large";
compact: boolean;
menuWidth: number;
smallerScrollbars: boolean;
showTimestamp: boolean;
@@ -10,12 +11,15 @@ export type Settings = {
showAllContainers: boolean;
lightTheme: "auto" | "dark" | "light";
hourStyle: "auto" | "24" | "12";
dateLocale: "auto" | "en-US" | "en-GB" | "de-DE" | "en-CA";
softWrap: boolean;
collapseNav: boolean;
automaticRedirect: boolean;
locale: string;
};
export const DEFAULT_SETTINGS: Settings = {
search: true,
compact: false,
size: "medium",
menuWidth: 15,
smallerScrollbars: false,
@@ -24,17 +28,21 @@ export const DEFAULT_SETTINGS: Settings = {
showAllContainers: false,
lightTheme: "auto",
hourStyle: "auto",
dateLocale: "auto",
softWrap: true,
collapseNav: false,
automaticRedirect: true,
locale: "",
};
export const settings = useProfileStorage("settings", DEFAULT_SETTINGS);
export const {
collapseNav,
compact,
softWrap,
hourStyle,
dateLocale,
lightTheme,
showAllContainers,
showTimestamp,
@@ -43,5 +51,6 @@ export const {
menuWidth,
size,
search,
locale,
automaticRedirect,
} = toRefs(settings);
+1
View File
@@ -15,6 +15,7 @@ export type ContainerJson = {
readonly state: ContainerState;
readonly host: string;
readonly labels: Record<string, string>;
readonly stats: ContainerStat[];
readonly health?: ContainerHealth;
};
+24
View File
@@ -51,3 +51,27 @@ export function useExponentialMovingAverage<T extends Record<string, number>>(so
return ema;
}
interface UseSimpleRefHistoryOptions<T> {
capacity: number;
deep?: boolean;
initial?: T[];
}
export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHistoryOptions<T>) {
const { capacity, deep = true, initial = [] as T[] } = options;
const history = ref<T[]>(initial) as Ref<T[]>;
watch(
source,
(value) => {
history.value.push(value);
if (history.value.length > capacity) {
history.value.shift();
}
},
{ deep },
);
return history;
}
+13 -6
View File
@@ -3,7 +3,7 @@ services:
custom_base:
container_name: custom_base
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOZZLE_FILTER=name=custom_base
- DOZZLE_BASE=/foobarbase
@@ -15,7 +15,7 @@ services:
simple-auth:
container_name: simple-auth
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./e2e/data:/data
environment:
- DOZZLE_AUTH_PROVIDER=simple
@@ -25,7 +25,7 @@ services:
dozzle:
container_name: dozzle
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOZZLE_FILTER=name=dozzle
- DOZZLE_NO_ANALYTICS=1
@@ -44,21 +44,28 @@ services:
build:
context: .
depends_on:
- proxy
proxy:
condition: service_healthy
proxy:
container_name: proxy
image: tecnativa/docker-socket-proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
interval: 5s
retries: 5
start_period: 5s
start_interval: 5s
ports:
- 2375:2375
playwright:
container_name: playwright
image: mcr.microsoft.com/playwright:v1.40.1-jammy
image: mcr.microsoft.com/playwright:v1.44.0-jammy
working_dir: /app
volumes:
- .:/app
+1
View File
@@ -69,6 +69,7 @@ export default defineConfig({
{ text: "Hostname", link: "/guide/hostname" },
{ text: "Remote Hosts", link: "/guide/remote-hosts" },
{ text: "Container Actions", link: "/guide/actions" },
{ text: "Filters", link: "/guide/filters" },
{ text: "Supported Env Vars", link: "/guide/supported-env-vars" },
{ text: "Logging Files on Disk", link: "/guide/log-files-on-disk" },
{ text: "Analytics", link: "/guide/analytics" },
+1 -1
View File
@@ -1,10 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BrowserWindow: typeof import('./.vitepress/theme/components/BrowserWindow.vue')['default']
+2 -2
View File
@@ -4,11 +4,11 @@ title: Anonymous Analytics
# Data Collection of Analytics
Dozzle collects anonymous user configurations using Google Analytics. _Why?_ Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics is collected to prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live using [dashboard](/guide/usage-dashboard).
Dozzle collects anonymous user configurations using a simple beacon written in Go. _Why?_ Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics is collected to prioritize features and fixes based on how people use Dozzle.
## Where is data stored
Dozzle's sends anonymous data to Google Analytics. Retention policy is 14 months which is lowest [possible value for Google Analytics](https://support.google.com/analytics/answer/7667196).
Dozzle's sends anonymous data to DigitalOcean which is written to a flat file for processing.
## Opting out
+13 -2
View File
@@ -24,9 +24,10 @@ users:
email: me@email.net
```
Dozzle uses `email` to generate avatars using [Gravatar](https://gravatar.com/). It is optional.
> [!TIP]
> This file can be generated with `docker run amir20/dozzle generate` with v6.6.x. See [below](#generating-users-yml) for more details.
The password is hashed using `sha256` which can be generated with `echo -n 'secret-password' | shasum -a 256` or `echo -n 'secret-password' | sha256sum` on linux.
Dozzle uses `email` to generate avatars using [Gravatar](https://gravatar.com/). It is optional. The password is hashed using `sha256` which can be generated with `echo -n 'secret-password' | shasum -a 256` or `echo -n 'secret-password' | sha256sum` on linux.
You will need to mount this file for Dozzle to find it. Here is an example:
@@ -64,6 +65,16 @@ users:
Dozzle uses [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) to generate tokens for authentication. This token is saved in a cookie.
### Generating users.yml
Starting with version `v6.6.x`, Dozzle has a builtin `generate` sub-command which can be used to create a `users.yml`.
```sh
docker run amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" > users.yml
```
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run amir20/dozzle generate --help` displays all options.
## Forward Proxy
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
+3 -5
View File
@@ -4,12 +4,12 @@ title: Changing Application Base
# Changing Dozzle Base
Dozzle by default mounts to "/". This can be changed with the `--base` flag. For example, if you want to mount to "/foobar" then you can use `--base foobar` or the env variable `DOZZLE_BASE`.
Dozzle by default mounts to "/". This can be changed with the `--base` flag. For example, if you want to mount to "/foobar" then you can use `--base /foobar` or the env variable `DOZZLE_BASE`.
::: code-group
```sh
docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --base foobar
docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --base /foobar
```
```yaml [docker-compose.yml]
@@ -22,7 +22,7 @@ services:
ports:
- 8080:8080
environment:
DOZZLE_BASE: foobar
DOZZLE_BASE: /foobar
```
:::
@@ -35,8 +35,6 @@ Here is an example with Nginx and proxy Dozzle with a different base:
```conf
location ^~ /foobar/ {
include /config/nginx/proxy.conf;
include /config/nginx/resolver.conf;
set $upstream_app dozzle;
set $upstream_port 8080;
set $upstream_proto http;
+30
View File
@@ -0,0 +1,30 @@
---
title: Filter
---
# Filtering Containers
Dozzle supports conditional filtering similar to Docker's [--filter](https://docs.docker.com/reference/cli/docker/container/ls/#filter) with `DOZZLE_FILTER` or `--filter`. Filters are passed directly to Docker to limit what Dozzle can see. For example, filtering by label is supported with `--filter "label=color"`, which is similar to `docker ps` command with `docker ps --filter "label=color"`.
::: code-group
```sh
docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --filter label=color
```
```yaml [docker-compose.yml]
version: "3"
services:
dozzle:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 8080:8080
environment:
DOZZLE_FILTER: label=color
```
:::
Common filters are `name` or `label` to limit Dozzle's access to containers.
+3 -1
View File
@@ -8,7 +8,9 @@ Dozzle supports connecting to multiple remote hosts via `tcp://` using TLS and n
## Connecting to remote hosts
Remote hosts can be configured with `--remote-host` or `DOZZLE_REMOTE_HOST`. All certs must be mounted to `/certs` directory. The `/cert` directory expects to have `/certs/{ca,cert,key}.pem` or `/certs/{host}/{ca,cert,key}.pem` in case of multiple hosts.
Remote hosts can be configured with `--remote-host` or `DOZZLE_REMOTE_HOST`. All certs must be mounted to `/certs` directory. The `/certs` directory expects to have `/certs/{ca,cert,key}.pem` or `/certs/{host}/{ca,cert,key}.pem` in case of multiple hosts.
Note the `{host}` value referred to here is the IP or FQDN configured and not the [optional label](#adding-labels-to-hosts).
Multiple `--remote-host` flags can be used to specify multiple hosts. However, using `DOZZLE_REMOTE_HOST` the value should be comma separated.
+8 -6
View File
@@ -6,7 +6,7 @@ title: Home
hero:
name: "Dozzle"
tagline: A lightweight, web-based Docker log viewer that provides real-time monitoring and easy troubleshooting.
tagline: Real-time logging and monitoring for Docker in the browser
actions:
- theme: brand
text: Get Started
@@ -16,10 +16,12 @@ hero:
link: https://github.com/amir20/dozzle
features:
- title: Real-time logging
details: Allows you to view logs of other Docker containers in real-time. As new log entries are generated, they are streamed to the web interface without needing to refresh the page.
- title: Lightweight
details: An application written in Go consuming very little memory and CPU. It can be run alongside other containers without causing performance issues.
- title: Real-time Logging & Monitoring
details: Captures real-time Docker container logs, enabling quick and efficient issue diagnosis.
- title: Easy Installation and Use
details: Offers a quick, simple setup and minimal configuration, making it user-friendly for those unfamiliar with Docker.
- title: Multi-host Support
details: Dozzle UI support connecting to multiple remote hosts with a simple drop down to choose between different hosts.
details: UI support connecting to multiple remote hosts with a simple drop down to choose between different hosts.
- title: No Database Required
details: Streams logs directly from Docker, remaining lightweight without extra overhead or complexity.
---
+4 -4
View File
@@ -3,11 +3,11 @@
"type": "module",
"name": "docs",
"devDependencies": {
"@unocss/preset-typography": "^0.58.3",
"@unocss/reset": "^0.58.3",
"@unocss/transformer-directives": "^0.58.3",
"@unocss/preset-typography": "^0.60.2",
"@unocss/reset": "^0.60.2",
"@unocss/transformer-directives": "^0.60.2",
"dozzle": "workspace:*",
"sitemap": "^7.1.1",
"unocss": "^0.58.3"
"unocss": "^0.60.2"
}
}
+1346 -1125
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -9,10 +9,7 @@ test("has right title", async ({ page }) => {
});
test("has dashboard text", async ({ page }) => {
await expect(page.getByText("Total Containers")).toBeVisible();
await expect(page.getByText("Total CPU Usage")).toBeVisible();
await expect(page.getByText("Total Mem Usage")).toBeVisible();
await expect(page.getByText("Dozzle Version")).toBeVisible();
await expect(page.getByText("container name")).toBeVisible();
});
test("click on settings button", async ({ page }) => {
@@ -34,6 +31,6 @@ test.describe("es locale", () => {
test.use({ locale: "es" });
test("translated text", async ({ page }) => {
await expect(page.getByTestId("containers")).toHaveText("Contenedores");
await expect(page.getByTestId("search")).toContainText("Buscar");
});
});
+1 -1
View File
@@ -5,5 +5,5 @@ test("simple authentication", async ({ page }) => {
await page.locator('input[name="username"]').fill("admin");
await page.locator('input[name="password"]').fill("password");
await page.locator('button[type="submit"]').click();
await expect(page.getByTestId("containers")).toHaveText("Containers");
await expect(page.getByTestId("settings")).toBeVisible();
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+35 -28
View File
@@ -1,12 +1,11 @@
module github.com/amir20/dozzle
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/alexflint/go-arg v1.4.3
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/alexflint/go-arg v1.5.0
github.com/beme/abide v0.0.0-20190723115211-635a09831760
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v24.0.7+incompatible
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/docker v26.1.2+incompatible
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1
github.com/gogo/protobuf v1.3.2 // indirect
@@ -14,49 +13,57 @@ require (
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/sirupsen/logrus v1.9.3
github.com/spf13/afero v1.11.0
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.4
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.9.0
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/go-chi/chi/v5 v5.0.11
github.com/go-chi/jwtauth/v5 v5.3.0
github.com/yuin/goldmark v1.6.0
github.com/yuin/goldmark-meta v1.1.0
github.com/PuerkitoBio/goquery v1.9.2
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/jwtauth/v5 v5.3.1
github.com/go-logfmt/logfmt v0.6.0
github.com/goccy/go-json v0.10.2
github.com/puzpuzpuz/xsync/v3 v3.1.0
github.com/yuin/goldmark v1.7.1
)
require (
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.5.5 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/kr/pretty v0.2.1 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/httprc v1.0.5 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.0.18 // indirect
github.com/lestrrat-go/jwx/v2 v2.0.21 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.16.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect
go.opentelemetry.io/otel/metric v1.26.0 // indirect
go.opentelemetry.io/otel/sdk v1.22.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/text v0.15.0 // indirect
gotest.tools/v3 v3.0.3 // indirect
)
go 1.21.5
go 1.22.3
+90 -71
View File
@@ -1,178 +1,192 @@
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo=
github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA=
github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/alexflint/go-arg v1.5.0 h1:rwMKGiaQuRbXfZNyRUvIfke63QvOBt1/QTshlGQHohM=
github.com/alexflint/go-arg v1.5.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beme/abide v0.0.0-20190723115211-635a09831760 h1:FvTM5NSN5HYvfKpgL+8x73U5v063vHsd7AX05eV1DnM=
github.com/beme/abide v0.0.0-20190723115211-635a09831760/go.mod h1:6+8gCKsZnxzhGTmKRh4BSkLos9CbWRJNcrp55We4SqQ=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v26.1.2+incompatible h1:UVX5ZOrrfTGZZYEP+ZDq3Xn9PdHNXaSYMFPDumMqG2k=
github.com/docker/docker v26.1.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/jwtauth/v5 v5.3.0 h1:X7RKGks1lrVeIe2omGyz47pNaNjG2YmwlRN5UKhN8qg=
github.com/go-chi/jwtauth/v5 v5.3.0/go.mod h1:2PoGm/KbnzRN9ILY6HFZAI6fTnb1gEZAKogAyqkd6fY=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk=
github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.0.18 h1:HHZkYS5wWDDyAiNBwztEtDoX07WDhGEdixm8G06R50o=
github.com/lestrrat-go/jwx/v2 v2.0.18/go.mod h1:fAJ+k5eTgKdDqanzCuK6DAt3W7n3cs2/FX7JhQdk83U=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0=
github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 h1:rzf0wL0CHVc8CEsgyygG0Mn9CNCCPZqOPaz8RiiHYQk=
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.1.0 h1:EewKT7/LNac5SLiEblJeUu8z5eERHrmRLnMQL2d7qX4=
github.com/puzpuzpuz/xsync/v3 v3.1.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 h1:FyjCyI9jVEfqhUh2MoSkmolPjfh5fp2hnV0b0irxH4Q=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY=
go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -182,18 +196,23 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-73
View File
@@ -1,73 +0,0 @@
package analytics
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httputil"
log "github.com/sirupsen/logrus"
)
func SendStartEvent(se StartEvent) error {
postBody := map[string]interface{}{
"client_id": se.ClientId,
"events": []map[string]interface{}{
{
"name": "start",
"params": se,
},
},
}
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
}
req, err := http.NewRequest("POST", "https://www.google-analytics.com/mp/collect", bytes.NewBuffer(jsonValue))
if err != nil {
return err
}
q := req.URL.Query()
q.Add("measurement_id", "G-S6NT05VXK9")
q.Add("api_secret", "7FFhe65HQK-bXvujpQMquQ")
req.URL.RawQuery = q.Encode()
response, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
dump, err := httputil.DumpResponse(response, true)
if err != nil {
return err
}
log.Debugf("%v", string(dump))
return fmt.Errorf("google analytics returned non-2xx status code: %v", response.Status)
}
return nil
}
+1
View File
@@ -11,6 +11,7 @@ import (
)
func SendBeacon(e BeaconEvent) error {
log.Tracef("sending beacon: %+v", e)
jsonValue, err := json.Marshal(e)
if err != nil {
return err
+3 -18
View File
@@ -1,22 +1,5 @@
package analytics
type StartEvent struct {
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"`
}
type BeaconEvent struct {
Name string `json:"name"`
Version string `json:"version"`
@@ -24,10 +7,12 @@ type BeaconEvent struct {
AuthProvider string `json:"authProvider"`
FilterLength int `json:"filterLength"`
Clients int `json:"clients"`
HasDocumentation bool `json:"hasDocumentation"`
HasCustomAddress bool `json:"hasCustomAddress"`
HasCustomBase bool `json:"hasCustomBase"`
HasHostname bool `json:"hasHostname"`
RunningContainers int `json:"runningContainers"`
HasActions bool `json:"hasActions"`
IsSwarmMode bool `json:"isSwarmMode"`
ServerVersion string `json:"serverVersion"`
ServerID string `json:"serverID"`
}
+24 -1
View File
@@ -1,6 +1,7 @@
package auth
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -16,7 +17,7 @@ import (
)
type User struct {
Username string `json:"username"`
Username string `json:"username" yaml:"-"`
Email string `json:"email" yaml:"email"`
Name string `json:"name" yaml:"name"`
Password string `json:"-" yaml:"password"`
@@ -56,6 +57,24 @@ func ReadUsersFromFile(path string) (UserDatabase, error) {
return users, nil
}
func GenerateUsers(user User, hashPassword bool) *bytes.Buffer {
buffer := &bytes.Buffer{}
if hashPassword {
user.Password = sha256sum(user.Password)
}
users := UserDatabase{
Users: map[string]*User{
user.Username: &user,
},
}
yaml.NewEncoder(buffer).Encode(users)
return buffer
}
func decodeUsersFromFile(path string) (UserDatabase, error) {
users := UserDatabase{}
file, err := os.Open(path)
@@ -74,6 +93,10 @@ func decodeUsersFromFile(path string) (UserDatabase, error) {
log.Fatalf("User %s has no password", username)
}
if len(user.Password) != 64 {
log.Fatalf("User %s has an invalid password hash", username)
}
if user.Name == "" {
user.Name = username
}
+46
View File
@@ -0,0 +1,46 @@
package cache
import (
"time"
log "github.com/sirupsen/logrus"
)
type Cache[T any] struct {
f func() (T, error)
Timestamp time.Time
Duration time.Duration
Data T
}
func New[T any](f func() (T, error), duration time.Duration) *Cache[T] {
return &Cache[T]{
f: f,
Duration: duration,
}
}
func (c *Cache[T]) GetWithHit() (T, error, bool) {
hit := true
if c.Timestamp.IsZero() || time.Since(c.Timestamp) > c.Duration {
hit = false
var err error
c.Data, err = c.f()
if err != nil {
return c.Data, err, hit
}
c.Timestamp = time.Now()
}
if hit {
log.Debugf("Cache hit for %T", c.Data)
} else {
log.Debugf("Cache miss for %T", c.Data)
}
return c.Data, nil, hit
}
func (c *Cache[T]) Get() (T, error) {
data, err, _ := c.GetWithHit()
return data, err
}
+44
View File
@@ -15,3 +15,47 @@ func calculateMemUsageUnixNoCache(mem types.MemoryStats) float64 {
}
return float64(mem.Usage)
}
func calculateCPUPercentWindows(v *types.StatsJSON) float64 {
// Max number of 100ns intervals between the previous time read and now
possIntervals := uint64(v.Read.Sub(v.PreRead).Nanoseconds()) // Start with number of ns intervals
possIntervals /= 100 // Convert to number of 100ns intervals
possIntervals *= uint64(v.NumProcs) // Multiple by the number of processors
// Intervals used
intervalsUsed := v.CPUStats.CPUUsage.TotalUsage - v.PreCPUStats.CPUUsage.TotalUsage
// Percentage avoiding divide-by-zero
if possIntervals > 0 {
return float64(intervalsUsed) / float64(possIntervals) * 100.0
}
return 0.00
}
func calculateMemPercentUnixNoCache(limit float64, usedNoCache float64) float64 {
// MemoryStats.Limit will never be 0 unless the container is not running and we haven't
// got any data from cgroup
if limit != 0 {
return usedNoCache / limit * 100.0
}
return 0
}
func calculateCPUPercentUnix(previousCPU, previousSystem uint64, v *types.StatsJSON) float64 {
var (
cpuPercent = 0.0
// calculate the change for the cpu usage of the container in between readings
cpuDelta = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(previousCPU)
// calculate the change for the entire system between readings
systemDelta = float64(v.CPUStats.SystemUsage) - float64(previousSystem)
onlineCPUs = float64(v.CPUStats.OnlineCPUs)
)
if onlineCPUs == 0.0 {
onlineCPUs = float64(len(v.CPUStats.CPUUsage.PercpuUsage))
}
if systemDelta > 0.0 && cpuDelta > 0.0 {
cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100.0
}
return cpuPercent
}
+118 -81
View File
@@ -2,7 +2,6 @@ package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"path/filepath"
@@ -12,10 +11,15 @@ import (
"strings"
"time"
"github.com/goccy/go-json"
"github.com/amir20/dozzle/internal/utils"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
@@ -44,29 +48,60 @@ func (s StdType) String() string {
}
type DockerCLI interface {
ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error)
ContainerLogs(context.Context, string, types.ContainerLogsOptions) (io.ReadCloser, error)
ContainerList(context.Context, container.ListOptions) ([]types.Container, error)
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error)
ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error)
Ping(ctx context.Context) (types.Ping, error)
ContainerStart(ctx context.Context, containerID string, options types.ContainerStartOptions) error
ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error
ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error
ContainerRestart(ctx context.Context, containerID string, options container.StopOptions) error
Info(ctx context.Context) (system.Info, error)
}
type Client struct {
type Client interface {
ListContainers() ([]Container, error)
FindContainer(string) (Container, error)
ContainerLogs(context.Context, string, string, StdType) (io.ReadCloser, error)
Events(context.Context, chan<- ContainerEvent) error
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error)
ContainerStats(context.Context, string, chan<- ContainerStat) error
Ping(context.Context) (types.Ping, error)
Host() *Host
ContainerActions(action string, containerID string) error
IsSwarmMode() bool
SystemInfo() system.Info
}
type httpClient struct {
cli DockerCLI
filters filters.Args
host *Host
info system.Info
}
func NewClient(cli DockerCLI, filters filters.Args, host *Host) *Client {
return &Client{cli, filters, host}
func NewClient(cli DockerCLI, filters filters.Args, host *Host) Client {
client := &httpClient{
cli: cli,
filters: filters,
host: host,
}
var err error
client.info, err = cli.Info(context.Background())
if err != nil {
log.Errorf("unable to get docker info: %v", err)
}
host.NCPU = client.info.NCPU
host.MemTotal = client.info.MemTotal
return client
}
// NewClientWithFilters creates a new instance of Client with docker filters
func NewClientWithFilters(f map[string][]string) (*Client, error) {
func NewClientWithFilters(f map[string][]string) (Client, error) {
filterArgs := filters.NewArgs()
for key, values := range f {
for _, value := range values {
@@ -85,7 +120,7 @@ func NewClientWithFilters(f map[string][]string) (*Client, error) {
return NewClient(cli, filterArgs, &Host{Name: "localhost", ID: "localhost"}), nil
}
func NewClientWithTlsAndFilter(f map[string][]string, host Host) (*Client, error) {
func NewClientWithTlsAndFilter(f map[string][]string, host Host) (Client, error) {
filterArgs := filters.NewArgs()
for key, values := range f {
for _, value := range values {
@@ -121,7 +156,7 @@ func NewClientWithTlsAndFilter(f map[string][]string, host Host) (*Client, error
return NewClient(cli, filterArgs, &host), nil
}
func (d *Client) FindContainer(id string) (Container, error) {
func (d *httpClient) FindContainer(id string) (Container, error) {
var container Container
containers, err := d.ListContainers()
if err != nil {
@@ -149,10 +184,10 @@ func (d *Client) FindContainer(id string) (Container, error) {
return container, nil
}
func (d *Client) ContainerActions(action string, containerID string) error {
func (d *httpClient) ContainerActions(action string, containerID string) error {
switch action {
case "start":
return d.cli.ContainerStart(context.Background(), containerID, types.ContainerStartOptions{})
return d.cli.ContainerStart(context.Background(), containerID, container.StartOptions{})
case "stop":
return d.cli.ContainerStop(context.Background(), containerID, container.StopOptions{})
case "restart":
@@ -162,8 +197,8 @@ func (d *Client) ContainerActions(action string, containerID string) error {
}
}
func (d *Client) ListContainers() ([]Container, error) {
containerListOptions := types.ContainerListOptions{
func (d *httpClient) ListContainers() ([]Container, error) {
containerListOptions := container.ListOptions{
Filters: d.filters,
All: true,
}
@@ -191,6 +226,7 @@ func (d *Client) ListContainers() ([]Container, error) {
Host: d.host.ID,
Health: findBetweenParentheses(c.Status),
Labels: c.Labels,
Stats: utils.NewRingBuffer[ContainerStat](300), // 300 seconds of stats
}
containers = append(containers, container)
}
@@ -202,59 +238,57 @@ func (d *Client) ListContainers() ([]Container, error) {
return containers, nil
}
func (d *Client) ContainerStats(ctx context.Context, id string, stats chan<- ContainerStat) error {
func (d *httpClient) ContainerStats(ctx context.Context, id string, stats chan<- ContainerStat) error {
response, err := d.cli.ContainerStats(ctx, id, true)
if err != nil {
return err
}
go func() {
log.Debugf("starting to stream stats for: %s", id)
defer response.Body.Close()
decoder := json.NewDecoder(response.Body)
var v *types.StatsJSON
for {
if err := decoder.Decode(&v); err != nil {
if err == context.Canceled || err == io.EOF {
log.Debugf("stopping stats streaming for container %s", id)
return
}
log.Errorf("decoder for stats api returned an unknown error %v", err)
}
defer response.Body.Close()
decoder := json.NewDecoder(response.Body)
var v *types.StatsJSON
for {
if err := decoder.Decode(&v); err != nil {
return err
}
ncpus := uint8(v.CPUStats.OnlineCPUs)
if ncpus == 0 {
ncpus = uint8(len(v.CPUStats.CPUUsage.PercpuUsage))
}
var (
memPercent, cpuPercent float64
mem, memLimit float64
previousCPU uint64
previousSystem uint64
)
daemonOSType := response.OSType
var (
cpuDelta = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage)
systemDelta = float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage)
cpuPercent = int64((cpuDelta / systemDelta) * float64(ncpus) * 100)
memUsage = int64(calculateMemUsageUnixNoCache(v.MemoryStats))
memPercent = int64(float64(memUsage) / float64(v.MemoryStats.Limit) * 100)
)
if daemonOSType != "windows" {
previousCPU = v.PreCPUStats.CPUUsage.TotalUsage
previousSystem = v.PreCPUStats.SystemUsage
cpuPercent = calculateCPUPercentUnix(previousCPU, previousSystem, v)
mem = calculateMemUsageUnixNoCache(v.MemoryStats)
memLimit = float64(v.MemoryStats.Limit)
memPercent = calculateMemPercentUnixNoCache(memLimit, mem)
} else {
cpuPercent = calculateCPUPercentWindows(v)
mem = float64(v.MemoryStats.PrivateWorkingSet)
}
if cpuPercent > 0 || memUsage > 0 {
select {
case <-ctx.Done():
return
case stats <- ContainerStat{
ID: id,
CPUPercent: cpuPercent,
MemoryPercent: memPercent,
MemoryUsage: memUsage,
}:
}
if cpuPercent > 0 || mem > 0 {
select {
case <-ctx.Done():
return nil
case stats <- ContainerStat{
ID: id,
CPUPercent: cpuPercent,
MemoryPercent: memPercent,
MemoryUsage: mem,
}:
}
}
}()
return nil
}
}
func (d *Client) ContainerLogs(ctx context.Context, id string, since string, stdType StdType) (io.ReadCloser, error) {
func (d *httpClient) ContainerLogs(ctx context.Context, id string, since string, stdType StdType) (io.ReadCloser, error) {
log.WithField("id", id).WithField("since", since).WithField("stdType", stdType).Debug("streaming logs for container")
if since != "" {
@@ -265,7 +299,7 @@ func (d *Client) ContainerLogs(ctx context.Context, id string, since string, std
}
}
options := types.ContainerLogsOptions{
options := container.LogsOptions{
ShowStdout: stdType&STDOUT != 0,
ShowStderr: stdType&STDERR != 0,
Follow: true,
@@ -282,41 +316,36 @@ func (d *Client) ContainerLogs(ctx context.Context, id string, since string, std
return reader, nil
}
func (d *Client) Events(ctx context.Context, messages chan<- ContainerEvent) <-chan error {
dockerMessages, errors := d.cli.Events(ctx, types.EventsOptions{})
func (d *httpClient) Events(ctx context.Context, messages chan<- ContainerEvent) error {
dockerMessages, err := d.cli.Events(ctx, types.EventsOptions{})
go func() {
for {
select {
case <-ctx.Done():
return nil
case err := <-err:
return err
for {
select {
case <-ctx.Done():
return
case message, ok := <-dockerMessages:
if !ok {
return
}
if message.Type == "container" && len(message.Actor.ID) > 0 {
messages <- ContainerEvent{
ActorID: message.Actor.ID[:12],
Name: message.Action,
Host: d.host.ID,
}
case message := <-dockerMessages:
if message.Type == "container" && len(message.Actor.ID) > 0 {
messages <- ContainerEvent{
ActorID: message.Actor.ID[:12],
Name: string(message.Action),
Host: d.host.ID,
}
}
}
}()
}
return errors
}
func (d *Client) ContainerLogsBetweenDates(ctx context.Context, id string, from time.Time, to time.Time, stdType StdType) (io.ReadCloser, error) {
options := types.ContainerLogsOptions{
func (d *httpClient) ContainerLogsBetweenDates(ctx context.Context, id string, from time.Time, to time.Time, stdType StdType) (io.ReadCloser, error) {
options := container.LogsOptions{
ShowStdout: stdType&STDOUT != 0,
ShowStderr: stdType&STDERR != 0,
Timestamps: true,
Since: from.Format(time.RFC3339),
Until: to.Format(time.RFC3339),
Since: from.Format(time.RFC3339Nano),
Until: to.Format(time.RFC3339Nano),
}
log.Debugf("fetching logs from Docker with option: %+v", options)
@@ -329,14 +358,22 @@ func (d *Client) ContainerLogsBetweenDates(ctx context.Context, id string, from
return reader, nil
}
func (d *Client) Ping(ctx context.Context) (types.Ping, error) {
func (d *httpClient) Ping(ctx context.Context) (types.Ping, error) {
return d.cli.Ping(ctx)
}
func (d *Client) Host() *Host {
func (d *httpClient) Host() *Host {
return d.host
}
func (d *httpClient) IsSwarmMode() bool {
return d.info.Swarm.LocalNodeState != swarm.LocalNodeStateInactive
}
func (d *httpClient) SystemInfo() system.Info {
return d.info
}
var PARENTHESIS_RE = regexp.MustCompile(`\(([a-zA-Z]+)\)`)
func findBetweenParentheses(s string) string {
+20 -41
View File
@@ -12,6 +12,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -22,7 +23,7 @@ type mockedProxy struct {
DockerCLI
}
func (m *mockedProxy) ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error) {
func (m *mockedProxy) ContainerList(context.Context, container.ListOptions) ([]types.Container, error) {
args := m.Called()
containers, ok := args.Get(0).([]types.Container)
if !ok && args.Get(0) != nil {
@@ -32,7 +33,7 @@ func (m *mockedProxy) ContainerList(context.Context, types.ContainerListOptions)
}
func (m *mockedProxy) ContainerLogs(ctx context.Context, id string, options types.ContainerLogsOptions) (io.ReadCloser, error) {
func (m *mockedProxy) ContainerLogs(ctx context.Context, id string, options container.LogsOptions) (io.ReadCloser, error) {
args := m.Called(ctx, id, options)
reader, ok := args.Get(0).(io.ReadCloser)
if !ok && args.Get(0) != nil {
@@ -50,7 +51,7 @@ func (m *mockedProxy) ContainerStats(ctx context.Context, containerID string, st
return types.ContainerStats{}, nil
}
func (m *mockedProxy) ContainerStart(ctx context.Context, containerID string, options types.ContainerStartOptions) error {
func (m *mockedProxy) ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error {
args := m.Called(ctx, containerID, options)
err := args.Get(0)
@@ -89,7 +90,7 @@ func (m *mockedProxy) ContainerRestart(ctx context.Context, containerID string,
func Test_dockerClient_ListContainers_null(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, nil)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
list, err := client.ListContainers()
assert.Empty(t, list, "list should be empty")
@@ -101,7 +102,7 @@ func Test_dockerClient_ListContainers_null(t *testing.T) {
func Test_dockerClient_ListContainers_error(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, errors.New("test"))
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
list, err := client.ListContainers()
assert.Nil(t, list, "list should be nil")
@@ -124,25 +125,15 @@ func Test_dockerClient_ListContainers_happy(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
list, err := client.ListContainers()
require.NoError(t, err, "error should not return an error.")
assert.Equal(t, list, []Container{
{
ID: "1234567890_a",
Name: "a_test_container",
Names: []string{"/a_test_container"},
Host: "localhost",
},
{
ID: "abcdefghijkl",
Name: "z_test_container",
Names: []string{"/z_test_container"},
Host: "localhost",
},
})
Ids := []string{"1234567890_a", "abcdefghijkl"}
for i, container := range list {
assert.Equal(t, container.ID, Ids[i])
}
proxy.AssertExpectations(t)
}
@@ -158,10 +149,10 @@ func Test_dockerClient_ContainerLogs_happy(t *testing.T) {
b = append(b, []byte(expected)...)
reader := io.NopCloser(bytes.NewReader(b))
options := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "300", Timestamps: true, Since: "since"}
options := container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "300", Timestamps: true, Since: "since"}
proxy.On("ContainerLogs", mock.Anything, id, options).Return(reader, nil)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
logReader, _ := client.ContainerLogs(context.Background(), id, "since", STDALL)
actual, _ := io.ReadAll(logReader)
@@ -175,7 +166,7 @@ func Test_dockerClient_ContainerLogs_error(t *testing.T) {
proxy.On("ContainerLogs", mock.Anything, id, mock.Anything).Return(nil, errors.New("test"))
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
reader, err := client.ContainerLogs(context.Background(), id, "", STDALL)
@@ -202,18 +193,12 @@ func Test_dockerClient_FindContainer_happy(t *testing.T) {
json := types.ContainerJSON{Config: &container.Config{Tty: false}}
proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
container, err := client.FindContainer("abcdefghijkl")
require.NoError(t, err, "error should not be thrown")
assert.Equal(t, container, Container{
ID: "abcdefghijkl",
Name: "z_test_container",
Names: []string{"/z_test_container"},
Host: "localhost",
Tty: false,
})
assert.Equal(t, container.ID, "abcdefghijkl")
proxy.AssertExpectations(t)
}
@@ -231,7 +216,7 @@ func Test_dockerClient_FindContainer_error(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
_, err := client.FindContainer("not_valid")
require.Error(t, err, "error should be thrown")
@@ -252,7 +237,7 @@ func Test_dockerClient_ContainerActions_happy(t *testing.T) {
}
proxy := new(mockedProxy)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
json := types.ContainerJSON{Config: &container.Config{Tty: false}}
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
@@ -263,13 +248,7 @@ func Test_dockerClient_ContainerActions_happy(t *testing.T) {
container, err := client.FindContainer("abcdefghijkl")
require.NoError(t, err, "error should not be thrown")
assert.Equal(t, container, Container{
ID: "abcdefghijkl",
Name: "z_test_container",
Names: []string{"/z_test_container"},
Host: "localhost",
Tty: false,
})
assert.Equal(t, container.ID, "abcdefghijkl")
actions := []string{"start", "stop", "restart"}
for _, action := range actions {
@@ -294,7 +273,7 @@ func Test_dockerClient_ContainerActions_error(t *testing.T) {
}
proxy := new(mockedProxy)
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
proxy.On("ContainerStart", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("test"))
+163
View File
@@ -0,0 +1,163 @@
package docker
import (
"context"
"errors"
"sync"
"sync/atomic"
"github.com/puzpuzpuz/xsync/v3"
log "github.com/sirupsen/logrus"
)
type ContainerStore struct {
containers *xsync.MapOf[string, *Container]
subscribers *xsync.MapOf[context.Context, chan ContainerEvent]
client Client
statsCollector *StatsCollector
wg sync.WaitGroup
connected atomic.Bool
events chan ContainerEvent
ctx context.Context
}
func NewContainerStore(ctx context.Context, client Client) *ContainerStore {
s := &ContainerStore{
containers: xsync.NewMapOf[string, *Container](),
client: client,
subscribers: xsync.NewMapOf[context.Context, chan ContainerEvent](),
statsCollector: NewStatsCollector(client),
wg: sync.WaitGroup{},
events: make(chan ContainerEvent),
ctx: ctx,
}
s.wg.Add(1)
go s.init()
return s
}
func (s *ContainerStore) checkConnectivity() error {
if s.connected.CompareAndSwap(false, true) {
go func() {
log.Debugf("subscribing to docker events from container store %s", s.client.Host())
err := s.client.Events(s.ctx, s.events)
if !errors.Is(err, context.Canceled) {
log.Errorf("docker store unexpectedly disconnected from docker events from %s with %v", s.client.Host(), err)
}
s.connected.Store(false)
}()
if containers, err := s.client.ListContainers(); err != nil {
return err
} else {
s.containers.Clear()
for _, c := range containers {
s.containers.Store(c.ID, &c)
}
}
}
return nil
}
func (s *ContainerStore) List() ([]Container, error) {
s.wg.Wait()
if err := s.checkConnectivity(); err != nil {
return nil, err
}
containers := make([]Container, 0)
s.containers.Range(func(_ string, c *Container) bool {
containers = append(containers, *c)
return true
})
return containers, nil
}
func (s *ContainerStore) Client() Client {
return s.client
}
func (s *ContainerStore) Subscribe(ctx context.Context, events chan ContainerEvent) {
go func() {
if s.statsCollector.Start(s.ctx) {
log.Debug("clearing container stats as stats collector has been stopped")
s.containers.Range(func(_ string, c *Container) bool {
c.Stats.Clear()
return true
})
}
}()
s.subscribers.Store(ctx, events)
}
func (s *ContainerStore) Unsubscribe(ctx context.Context) {
s.subscribers.Delete(ctx)
s.statsCollector.Stop()
}
func (s *ContainerStore) SubscribeStats(ctx context.Context, stats chan ContainerStat) {
s.statsCollector.Subscribe(ctx, stats)
}
func (s *ContainerStore) init() {
stats := make(chan ContainerStat)
s.statsCollector.Subscribe(s.ctx, stats)
s.checkConnectivity()
s.wg.Done()
for {
select {
case event := <-s.events:
log.Tracef("received event: %+v", event)
switch event.Name {
case "start":
if container, err := s.client.FindContainer(event.ActorID); err == nil {
log.Debugf("container %s started", container.ID)
s.containers.Store(container.ID, &container)
}
case "destroy":
log.Debugf("container %s destroyed", event.ActorID)
s.containers.Delete(event.ActorID)
case "die":
if container, ok := s.containers.Load(event.ActorID); ok {
log.Debugf("container %s died", container.ID)
container.State = "exited"
}
case "health_status: healthy", "health_status: unhealthy":
healthy := "unhealthy"
if event.Name == "health_status: healthy" {
healthy = "healthy"
}
if container, ok := s.containers.Load(event.ActorID); ok {
log.Debugf("container %s is %s", container.ID, healthy)
container.Health = healthy
}
}
s.subscribers.Range(func(c context.Context, events chan ContainerEvent) bool {
select {
case events <- event:
case <-c.Done():
s.subscribers.Delete(c)
}
return true
})
case stat := <-stats:
if container, ok := s.containers.Load(stat.ID); ok {
stat.ID = ""
container.Stats.Push(stat)
}
case <-s.ctx.Done():
return
}
}
}
+106
View File
@@ -0,0 +1,106 @@
package docker
import (
"context"
"testing"
"github.com/amir20/dozzle/internal/utils"
"github.com/magiconair/properties/assert"
"github.com/stretchr/testify/mock"
)
type mockedClient struct {
mock.Mock
Client
}
func (m *mockedClient) ListContainers() ([]Container, error) {
args := m.Called()
return args.Get(0).([]Container), args.Error(1)
}
func (m *mockedClient) FindContainer(id string) (Container, error) {
args := m.Called(id)
return args.Get(0).(Container), args.Error(1)
}
func (m *mockedClient) Events(ctx context.Context, events chan<- ContainerEvent) error {
args := m.Called(ctx, events)
return args.Error(0)
}
func (m *mockedClient) ContainerStats(ctx context.Context, id string, stats chan<- ContainerStat) error {
args := m.Called(ctx, id, stats)
return args.Error(0)
}
func (m *mockedClient) Host() *Host {
args := m.Called()
return args.Get(0).(*Host)
}
func TestContainerStore_List(t *testing.T) {
client := new(mockedClient)
client.On("ListContainers").Return([]Container{
{
ID: "1234",
Name: "test",
},
}, nil)
client.On("Events", mock.Anything, mock.AnythingOfType("chan<- docker.ContainerEvent")).Return(nil).Run(func(args mock.Arguments) {
ctx := args.Get(0).(context.Context)
<-ctx.Done()
})
client.On("Host").Return(&Host{
ID: "localhost",
})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
store := NewContainerStore(ctx, client)
containers, _ := store.List()
assert.Equal(t, containers[0].ID, "1234")
}
func TestContainerStore_die(t *testing.T) {
client := new(mockedClient)
client.On("ListContainers").Return([]Container{
{
ID: "1234",
Name: "test",
State: "running",
Stats: utils.NewRingBuffer[ContainerStat](300),
},
}, nil)
client.On("Events", mock.Anything, mock.AnythingOfType("chan<- docker.ContainerEvent")).Return(nil).
Run(func(args mock.Arguments) {
ctx := args.Get(0).(context.Context)
events := args.Get(1).(chan<- ContainerEvent)
events <- ContainerEvent{
Name: "die",
ActorID: "1234",
Host: "localhost",
}
<-ctx.Done()
})
client.On("Host").Return(&Host{
ID: "localhost",
})
client.On("ContainerStats", mock.Anything, "1234", mock.AnythingOfType("chan<- docker.ContainerStat")).Return(nil)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
store := NewContainerStore(ctx, client)
// Wait until we get the event
events := make(chan ContainerEvent)
store.Subscribe(ctx, events)
<-events
containers, _ := store.List()
assert.Equal(t, containers[0].State, "exited")
}
+30 -5
View File
@@ -4,15 +4,18 @@ import (
"bufio"
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"regexp"
"strings"
"sync"
"time"
"github.com/goccy/go-json"
"github.com/go-logfmt/logfmt"
log "github.com/sirupsen/logrus"
)
@@ -79,16 +82,14 @@ func (g *EventGenerator) consumeReader() {
message, streamType, readerError := readEvent(g.reader, g.tty)
if message != "" {
logEvent := createEvent(message, streamType)
if _, ok := logEvent.Message.(string); ok {
logEvent.Message = stripANSI(logEvent.Message.(string))
}
logEvent.Level = guessLogLevel(logEvent)
g.buffer <- logEvent
}
if readerError != nil {
if readerError != ErrBadHeader {
log.Debugf("reader error: %v", readerError)
log.Tracef("reader error: %v", readerError)
g.Errors <- readerError
close(g.buffer)
break
@@ -155,6 +156,9 @@ func readEvent(reader *bufio.Reader, tty bool) (string, StdType, error) {
}
}
var validLogFmtMessage = regexp.MustCompile(`([a-zA-Z0-9_.-]+)=(?:(?:"(.*)")|(?:(?:([^\s]+)[\s])))`)
var validLogFmtKey = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
func createEvent(message string, streamType StdType) *LogEvent {
h := fnv.New32a()
h.Write([]byte(message))
@@ -177,6 +181,27 @@ func createEvent(message string, streamType StdType) *LogEvent {
} else {
logEvent.Message = data
}
} else if validLogFmtMessage.MatchString(message) {
buffer := bufPool.Get().(*bytes.Buffer)
buffer.Reset()
defer bufPool.Put(buffer)
buffer.WriteString(message)
decoder := logfmt.NewDecoder(buffer)
data := make(map[string]string)
decoder.ScanRecord()
allValid := true
for decoder.ScanKeyval() {
key := decoder.Key()
value := decoder.Value()
if !validLogFmtKey.Match(key) {
allValid = false
break
}
data[string(key)] = string(value)
}
if allValid && len(data) > 1 {
logEvent.Message = data
}
}
}
}
+1 -3
View File
@@ -81,8 +81,7 @@ func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
func Test_createEvent(t *testing.T) {
type args struct {
message string
streamType StdType
message string
}
tests := []struct {
name string
@@ -153,6 +152,5 @@ func Benchmark_readEvent(b *testing.B) {
for i := 0; i < b.N; i++ {
readEvent(reader, true)
// println(message, stream)
}
}
+10 -1
View File
@@ -2,11 +2,12 @@ package docker
import (
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
)
type Host struct {
@@ -17,6 +18,12 @@ type Host struct {
CACertPath string `json:"-"`
KeyPath string `json:"-"`
ValidCerts bool `json:"-"`
NCPU int `json:"nCPU"`
MemTotal int64 `json:"memTotal"`
}
func (h *Host) String() string {
return h.ID
}
func ParseConnection(connection string) (Host, error) {
@@ -43,6 +50,8 @@ func ParseConnection(connection string) (Host, error) {
host := remoteUrl.Hostname()
if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) {
basePath = filepath.Join(basePath, host)
} else {
log.Debugf("Remote host certificate path does not exist %s, falling back to default: %s", filepath.Join(basePath, host), basePath)
}
cacertPath := filepath.Join(basePath, "ca.pem")
+18 -12
View File
@@ -5,30 +5,31 @@ import (
"strings"
)
var KEY_VALUE_REGEX = regexp.MustCompile(`level=(\w+)`)
var LOG_LEVELS = []string{"error", "warn", "warning", "info", "debug", "trace", "fatal"}
var LOG_LEVELS_PLAIN = map[string]*regexp.Regexp{}
var LOG_LEVEL_BRACKET = map[string]*regexp.Regexp{}
var keyValueRegex = regexp.MustCompile(`level=(\w+)`)
var logLevels = []string{"error", "warn", "warning", "info", "debug", "trace", "fatal"}
var plainLevels = map[string]*regexp.Regexp{}
var bracketLevels = map[string]*regexp.Regexp{}
func init() {
for _, level := range LOG_LEVELS {
LOG_LEVELS_PLAIN[level] = regexp.MustCompile("(?i)^" + level + "[^a-z]")
for _, level := range logLevels {
plainLevels[level] = regexp.MustCompile("(?i)^" + level + "[^a-z]")
}
for _, level := range LOG_LEVELS {
LOG_LEVEL_BRACKET[level] = regexp.MustCompile("(?i)\\[ ?" + level + " ?\\]")
for _, level := range logLevels {
bracketLevels[level] = regexp.MustCompile("(?i)\\[ ?" + level + " ?\\]")
}
}
func guessLogLevel(logEvent *LogEvent) string {
switch value := logEvent.Message.(type) {
case string:
for _, level := range LOG_LEVELS {
if LOG_LEVELS_PLAIN[level].MatchString(value) {
value = stripANSI(value)
for _, level := range logLevels {
if plainLevels[level].MatchString(value) {
return level
}
if LOG_LEVEL_BRACKET[level].MatchString(value) {
if bracketLevels[level].MatchString(value) {
return level
}
@@ -37,7 +38,7 @@ func guessLogLevel(logEvent *LogEvent) string {
}
}
if matches := KEY_VALUE_REGEX.FindStringSubmatch(value); matches != nil {
if matches := keyValueRegex.FindStringSubmatch(value); matches != nil {
return matches[1]
}
@@ -45,6 +46,11 @@ func guessLogLevel(logEvent *LogEvent) string {
if level, ok := value["level"].(string); ok {
return level
}
case map[string]string:
if level, ok := value["level"]; ok {
return level
}
}
return ""
+149
View File
@@ -0,0 +1,149 @@
package docker
import (
"context"
"errors"
"io"
"sync"
"sync/atomic"
"time"
"github.com/puzpuzpuz/xsync/v3"
log "github.com/sirupsen/logrus"
)
type StatsCollector struct {
stream chan ContainerStat
subscribers *xsync.MapOf[context.Context, chan ContainerStat]
client Client
cancelers *xsync.MapOf[string, context.CancelFunc]
stopper context.CancelFunc
timer *time.Timer
mu sync.Mutex
totalStarted atomic.Int32
}
var timeToStop = 6 * time.Hour
func NewStatsCollector(client Client) *StatsCollector {
return &StatsCollector{
stream: make(chan ContainerStat),
subscribers: xsync.NewMapOf[context.Context, chan ContainerStat](),
client: client,
cancelers: xsync.NewMapOf[string, context.CancelFunc](),
}
}
func (c *StatsCollector) Subscribe(ctx context.Context, stats chan ContainerStat) {
c.subscribers.Store(ctx, stats)
}
func (c *StatsCollector) forceStop() {
c.mu.Lock()
defer c.mu.Unlock()
if c.stopper != nil {
c.stopper()
c.stopper = nil
log.Debug("stopping container stats collector")
}
}
func (c *StatsCollector) Stop() {
c.mu.Lock()
defer c.mu.Unlock()
if c.totalStarted.Add(-1) == 0 {
log.Debugf("scheduled to stop container stats collector %s", c.client.Host())
c.timer = time.AfterFunc(timeToStop, func() {
c.forceStop()
})
}
}
func (c *StatsCollector) reset() {
c.mu.Lock()
defer c.mu.Unlock()
log.Debugf("resetting timer for container stats collector %s", c.client.Host())
if c.timer != nil {
c.timer.Stop()
}
c.timer = nil
}
func streamStats(parent context.Context, sc *StatsCollector, id string) {
ctx, cancel := context.WithCancel(parent)
sc.cancelers.Store(id, cancel)
log.Debugf("starting to stream stats for: %s", id)
if err := sc.client.ContainerStats(ctx, id, sc.stream); err != nil {
log.Debugf("stopping to stream stats for: %s", id)
if !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
log.Errorf("unexpected error when streaming container stats: %v", err)
}
}
}
// Start starts the stats collector and blocks until it's stopped. It returns true if the collector was stopped, false if it was already running
func (sc *StatsCollector) Start(parentCtx context.Context) bool {
sc.reset()
sc.totalStarted.Add(1)
var ctx context.Context
sc.mu.Lock()
if sc.stopper != nil {
sc.mu.Unlock()
return false
}
ctx, sc.stopper = context.WithCancel(parentCtx)
sc.mu.Unlock()
if containers, err := sc.client.ListContainers(); err == nil {
for _, c := range containers {
if c.State == "running" {
go streamStats(ctx, sc, c.ID)
}
}
} else {
log.Errorf("error while listing containers: %v", err)
}
events := make(chan ContainerEvent)
go func() {
log.Debugf("subscribing to docker events from stats collector %s", sc.client.Host())
err := sc.client.Events(context.Background(), events)
if !errors.Is(err, context.Canceled) {
log.Errorf("stats collector unexpectedly disconnected from docker events from %s with %v", sc.client.Host(), err)
}
sc.forceStop()
}()
go func() {
for event := range events {
switch event.Name {
case "start":
go streamStats(ctx, sc, event.ActorID)
case "die":
if cancel, ok := sc.cancelers.LoadAndDelete(event.ActorID); ok {
cancel()
}
}
}
}()
for {
select {
case <-ctx.Done():
log.Info("stopped collecting container stats")
return true
case stat := <-sc.stream:
sc.subscribers.Range(func(c context.Context, stats chan ContainerStat) bool {
select {
case stats <- stat:
case <-c.Done():
sc.subscribers.Delete(c)
}
return true
})
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package docker
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func startedCollector(ctx context.Context) *StatsCollector {
client := new(mockedClient)
client.On("ListContainers").Return([]Container{
{
ID: "1234",
Name: "test",
State: "running",
},
}, nil)
client.On("Events", mock.Anything, mock.AnythingOfType("chan<- docker.ContainerEvent")).
Return(nil).
Run(func(args mock.Arguments) {
ctx := args.Get(0).(context.Context)
<-ctx.Done()
})
client.On("ContainerStats", mock.Anything, mock.Anything, mock.AnythingOfType("chan<- docker.ContainerStat")).
Return(nil).
Run(func(args mock.Arguments) {
stats := args.Get(2).(chan<- ContainerStat)
stats <- ContainerStat{
ID: "1234",
}
})
client.On("Host").Return(&Host{
ID: "localhost",
})
collector := NewStatsCollector(client)
stats := make(chan ContainerStat)
collector.Subscribe(ctx, stats)
go collector.Start(ctx)
<-stats
return collector
}
func TestCancelers(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
collector := startedCollector(ctx)
_, ok := collector.cancelers.Load("1234")
assert.True(t, ok, "canceler should be stored")
assert.False(t, collector.Start(ctx), "second start should return false")
assert.Equal(t, int32(2), collector.totalStarted.Load(), "total started should be 2")
collector.Stop()
assert.Equal(t, int32(1), collector.totalStarted.Load(), "total started should be 1")
}
func TestSecondStart(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
collector := startedCollector(ctx)
assert.False(t, collector.Start(ctx), "second start should return false")
assert.Equal(t, int32(2), collector.totalStarted.Load(), "total started should be 2")
collector.Stop()
assert.Equal(t, int32(1), collector.totalStarted.Load(), "total started should be 1")
}
func TestStop(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
collector := startedCollector(ctx)
collector.Stop()
assert.Equal(t, int32(0), collector.totalStarted.Load(), "total started should be 1")
}
+20 -17
View File
@@ -2,31 +2,34 @@ package docker
import (
"math"
"github.com/amir20/dozzle/internal/utils"
)
// Container represents an internal representation of docker containers
type Container struct {
ID string `json:"id"`
Names []string `json:"names"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"imageId"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health,omitempty"`
Host string `json:"host,omitempty"`
Tty bool `json:"-"`
Labels map[string]string `json:"labels,omitempty"`
ID string `json:"id"`
Names []string `json:"names"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"imageId"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health,omitempty"`
Host string `json:"host,omitempty"`
Tty bool `json:"-"`
Labels map[string]string `json:"labels,omitempty"`
Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
}
// ContainerStat represent stats instant for a container
type ContainerStat struct {
ID string `json:"id"`
CPUPercent int64 `json:"cpu"`
MemoryPercent int64 `json:"memory"`
MemoryUsage int64 `json:"memoryUsage"`
ID string `json:"id"`
CPUPercent float64 `json:"cpu"`
MemoryPercent float64 `json:"memory"`
MemoryUsage float64 `json:"memoryUsage"`
}
// ContainerEvent represents events that are triggered
+1 -1
View File
@@ -35,5 +35,5 @@ func HttpRequest(addr string, base string) error {
return nil
}
return fmt.Errorf("Healthcheck failed with status code %d", resp.StatusCode)
return fmt.Errorf("healthcheck failed with status code %d", resp.StatusCode)
}
+11 -7
View File
@@ -17,7 +17,7 @@ const (
profileFilename = "profile.json"
)
var missingProfileErr = errors.New("Profile file does not exist")
var errMissingProfileErr = errors.New("Profile file does not exist")
type Settings struct {
Search bool `json:"search"`
@@ -30,15 +30,19 @@ type Settings struct {
CollapseNav bool `json:"collapseNav"`
AutomaticRedirect bool `json:"automaticRedirect"`
Size string `json:"size,omitempty"`
Compact bool `json:"compact"`
LightTheme string `json:"lightTheme,omitempty"`
HourStyle string `json:"hourStyle,omitempty"`
DateLocale string `json:"dateLocale,omitempty"`
Locale string `json:"locale"`
}
type Profile struct {
Settings *Settings `json:"settings,omitempty"`
Pinned []string `json:"pinned"`
VisibleKeys map[string][][]string `json:"visibleKeys,omitempty"`
ReleaseSeen string `json:"releaseSeen,omitempty"`
Settings *Settings `json:"settings,omitempty"`
Pinned []string `json:"pinned"`
VisibleKeys map[string][][]string `json:"visibleKeys,omitempty"`
ReleaseSeen string `json:"releaseSeen,omitempty"`
CollapsedGroups []string `json:"collapsedGroups"`
}
var dataPath string
@@ -63,7 +67,7 @@ func UpdateFromReader(user auth.User, reader io.Reader) error {
mux.Lock()
defer mux.Unlock()
existingProfile, err := Load(user)
if err != nil && err != missingProfileErr {
if err != nil && err != errMissingProfileErr {
return err
}
@@ -109,7 +113,7 @@ func Load(user auth.User) (Profile, error) {
profilePath := filepath.Join(path, profileFilename)
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
return Profile{}, missingProfileErr
return Profile{}, errMissingProfileErr
}
f, err := os.Open(profilePath)
+53
View File
@@ -0,0 +1,53 @@
package utils
import (
"sync"
"github.com/goccy/go-json"
)
type RingBuffer[T any] struct {
Size int
data []T
start int
mutex sync.RWMutex
}
func NewRingBuffer[T any](size int) *RingBuffer[T] {
return &RingBuffer[T]{
Size: size,
data: make([]T, 0, size),
}
}
func (r *RingBuffer[T]) Push(data T) {
r.mutex.Lock()
defer r.mutex.Unlock()
if len(r.data) == r.Size {
r.data[r.start] = data
r.start = (r.start + 1) % r.Size
} else {
r.data = append(r.data, data)
}
}
func (r *RingBuffer[T]) Clear() {
r.mutex.Lock()
defer r.mutex.Unlock()
r.data = r.data[:0]
r.start = 0
}
func (r *RingBuffer[T]) Data() []T {
r.mutex.RLock()
defer r.mutex.RUnlock()
if len(r.data) == r.Size {
return append(r.data[r.start:], r.data[:r.start]...)
} else {
return r.data
}
}
func (r *RingBuffer[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data())
}
+60
View File
@@ -0,0 +1,60 @@
package utils
import (
"reflect"
"testing"
)
func TestRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
data := rb.Data()
expectedData := []int{1, 2, 3}
if !reflect.DeepEqual(data, expectedData) {
t.Errorf("Expected data to be %v, got %v", expectedData, data)
}
rb.Push(4)
data = rb.Data()
expectedData = []int{2, 3, 4}
if !reflect.DeepEqual(data, expectedData) {
t.Errorf("Expected data to be %v, got %v", expectedData, data)
}
}
func TestRingBuffer_MarshalJSON(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
data, err := rb.MarshalJSON()
if err != nil {
t.Errorf("Expected error to be nil, got %v", err)
}
expectedData := []byte("[1,2,3]")
if !reflect.DeepEqual(data, expectedData) {
t.Errorf("Expected data to be %v, got %v", expectedData, data)
}
}
func TestRingBuffer_Clear(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
rb.Clear()
data := rb.Data()
expectedData := []int{}
if !reflect.DeepEqual(data, expectedData) {
t.Errorf("Expected data to be %v, got %v", expectedData, data)
}
}

Some files were not shown because too many files have changed in this diff Show More