Compare commits

...

123 Commits

Author SHA1 Message Date
Amir Raminfar ce10266bf2 chore: release v7.0.2 2024-05-28 15:08:15 -07:00
Amir Raminfar 22975208af fix: fixes typo. fixes #2991 2024-05-28 15:06:44 -07:00
Amir Raminfar aed903d57d chore: release v7.0.1 2024-05-28 09:02:29 -07:00
renovate[bot] 98a8f4ac81 chore(deps): update all non-major dependencies (#2990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-28 15:13:31 +00:00
Amir Raminfar a65f8a2c3a chore: css clean up (#2988) 2024-05-27 20:43:22 +00:00
renovate[bot] 4dd99c209c fix(deps): update all non-major dependencies (#2987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-27 14:48:20 +00:00
Amir Raminfar 518e3c8ac9 fix: fixes bug when switching between merged logs was not working 2024-05-26 18:40:53 -07:00
renovate[bot] 19c800fc27 chore(deps): update all non-major dependencies (#2986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-27 00:05:31 +00:00
Amir Raminfar 34dcf9d068 chore: fixes reverse bug on stats 2024-05-26 12:34:55 -07:00
Amir Raminfar beb95c2f87 fix: improves the loading of stats between page changes (#2985) 2024-05-26 17:29:26 +00:00
Amir Raminfar 3b34578e92 feat: adds a simple loader when waiting for logs (#2984) 2024-05-26 10:25:20 -07:00
Amir Raminfar 89d9e67862 fix: fixes when only one container is in a service (#2983) 2024-05-25 15:56:20 -07:00
Amir Raminfar c268fa4e8b fix: bug when a new container in service mode is created (#2982) 2024-05-24 13:04:50 -07:00
Amir Raminfar a089b394e0 chore: updates buy me button 2024-05-24 10:37:10 -07:00
Amir Raminfar 555c0b7222 docs: generates better sitemap 2024-05-24 10:08:30 -07:00
Amir Raminfar 2a36a45fe9 docs: sponsors (#2981) 2024-05-24 09:31:24 -07:00
Amir Raminfar 57bcf874fd chore(docs): updates info on swarm mode 2024-05-24 08:01:48 -07:00
Amir Raminfar 56339b3959 chore: release v7.0.0 2024-05-24 08:01:10 -07:00
Amir Raminfar 2e774481c1 chore: minor fix on padding and regression 2024-05-23 18:27:42 -07:00
Amir Raminfar aed171cb6b fix: fixes padding on mobile (#2980) 2024-05-23 23:25:04 +00:00
Amir Raminfar 4c7f281257 fix: uses better colors for different container names (#2979) 2024-05-23 21:46:57 +00:00
Amir Raminfar 6acb7126f5 fix: fixes menu bug when merging logs (#2978) 2024-05-23 20:26:28 +00:00
Amir Raminfar 9408a9ac45 docs: updates docs for INFO (#2977) 2024-05-23 19:27:59 +00:00
Amir Raminfar f644d57964 feat: shows offline also on dashboard host list 2024-05-23 12:24:58 -07:00
Amir Raminfar 9dfbf7463c docs: adds documentation for swarm mode (#2976) 2024-05-23 11:42:14 -07:00
renovate[bot] 1f19785c99 fix(deps): update all non-major dependencies (#2975)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-23 17:22:23 +00:00
Amir Raminfar ba0206a903 feat: supports swarm mode with stacks and services on remote hosts 🙌🏼 (#2961) 2024-05-23 10:17:16 -07:00
Amir Raminfar 68908e57b7 chore: release v6.6.3 2024-05-23 08:30:20 -07:00
Amir Raminfar b97e0d28cd chore: changes implementation of ordered map (#2974) 2024-05-23 08:26:34 -07:00
Amir Raminfar 1d1f3ed36a feat: keeps the order of json keys. see fixes #2970 (#2971) 2024-05-22 15:59:54 -07:00
renovate[bot] 38352379ab chore(deps): update dependency sitemap to v8 (#2973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-22 12:30:45 -07:00
renovate[bot] cb602957bc chore(deps): update all non-major dependencies (#2972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-22 19:25:08 +00:00
Richard Hull 05edd49487 fix: Coerce log level to lowercase when guessing in structured json (#2968) 2024-05-22 06:07:45 -07:00
renovate[bot] b326dca06d chore(deps): update all non-major dependencies (#2966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-21 18:02:31 +00:00
Amir Raminfar 219e20fdba chore: fixes race condition in container store (#2965) 2024-05-21 10:57:16 -07:00
renovate[bot] bc627a725f chore(deps): update all non-major dependencies (#2952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-17 23:40:09 +00:00
Amir Raminfar 0c9c09dc4c chore: ignores vue typescript error temporary 2024-05-17 16:31:54 -07:00
Amir Raminfar 5810383a29 fix: only shows event stream error when trying to reconnect (#2958) 2024-05-14 06:13:46 -07:00
Amir Raminfar cbda1e753c chore: fixes race condition (#2959) 2024-05-13 15:32:51 -07:00
LovelessCodes b1f980f49b feat: Danish translation file (#2957) 2024-05-13 13:09:51 -07:00
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
151 changed files with 9403 additions and 6213 deletions
+2 -2
View File
@@ -84,9 +84,9 @@ jobs:
with:
images: amir20/dozzle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # v3.2.0
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
- name: Login to DockerHub
uses: docker/login-action@v3.1.0
uses: docker/login-action@v3.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
with:
images: amir20/dozzle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # v3.2.0
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
- name: Login to DockerHub
uses: docker/login-action@v3.1.0
uses: docker/login-action@v3.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
+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
+4 -4
View File
@@ -60,10 +60,10 @@ jobs:
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.0
# with:
# install-go: false
- name: Stactic checker
uses: dominikh/staticcheck-action@v1.3.1
with:
install-go: false
int-test:
name: Integration Tests
timeout-minutes: 60
+2 -2
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
@@ -22,7 +22,7 @@ COPY public ./public
# Build assets
RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.22.1-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.22.3-alpine AS builder
RUN apk add --no-cache ca-certificates && mkdir /dozzle
+39 -13
View File
@@ -58,6 +58,7 @@ declare global {
const getDeep: typeof import('./utils/index')['getDeep']
const globalShowPopup: typeof import('./composable/popup')['globalShowPopup']
const h: typeof import('vue')['h']
const hashCode: typeof import('./utils/index')['hashCode']
const hourStyle: typeof import('./stores/settings')['hourStyle']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
@@ -71,7 +72,7 @@ declare global {
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 loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
@@ -101,11 +102,12 @@ declare global {
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const persistentVisibleKeys: typeof import('./composable/storage')['persistentVisibleKeys']
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
const provide: typeof import('vue')['provide']
const provideContainerContext: typeof import('./composable/containerContext')['provideContainerContext']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const provideLoggingContext: typeof import('./composable/logContext')['provideLoggingContext']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
@@ -190,6 +192,7 @@ declare global {
const useContainerActions: typeof import('./composable/containerActions')['useContainerActions']
const useContainerContext: typeof import('./composable/containerContext')['useContainerContext']
const useContainerStore: typeof import('./stores/container')['useContainerStore']
const useContainerStream: typeof import('./composable/eventStreams')['useContainerStream']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
@@ -229,7 +232,9 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
const useHead: typeof import('@vueuse/head')['useHead']
const 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']
@@ -242,13 +247,14 @@ declare global {
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useLogSearchContext: typeof import('./composable/logSearchContext')['useLogSearchContext']
const useLogStream: typeof import('./composable/eventsource')['useLogStream']
const useLoggingContext: typeof import('./composable/logContext')['useLoggingContext']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMergedStream: typeof import('./composable/eventStreams')['useMergedStream']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
@@ -265,6 +271,7 @@ declare global {
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePinnedLogsStore: typeof import('./stores/pinned')['usePinnedLogsStore']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
@@ -288,6 +295,7 @@ declare global {
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSearchFilter: typeof import('./composable/search')['useSearchFilter']
const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta']
const useServiceStream: typeof import('./composable/eventStreams')['useServiceStream']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory']
@@ -295,11 +303,13 @@ declare global {
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStackStream: typeof import('./composable/eventStreams')['useStackStream']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwarmStore: typeof import('./stores/swarm')['useSwarmStore']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
@@ -385,7 +395,6 @@ declare module 'vue' {
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly config: UnwrapRef<typeof import('./stores/config')['default']>
readonly containerContext: UnwrapRef<typeof import('./composable/containerContext')['containerContext']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
@@ -416,6 +425,7 @@ declare module 'vue' {
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
@@ -429,6 +439,7 @@ declare module 'vue' {
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -458,11 +469,11 @@ declare module 'vue' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly persistentVisibleKeys: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeys']>
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly provideLoggingContext: UnwrapRef<typeof import('./composable/logContext')['provideLoggingContext']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -545,8 +556,8 @@ declare module 'vue' {
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']>
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
readonly useContainerStream: UnwrapRef<typeof import('./composable/eventStreams')['useContainerStream']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
@@ -586,7 +597,9 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
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']>
@@ -599,13 +612,14 @@ declare module 'vue' {
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']>
readonly useLogStream: UnwrapRef<typeof import('./composable/eventsource')['useLogStream']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMergedStream: UnwrapRef<typeof import('./composable/eventStreams')['useMergedStream']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -622,6 +636,7 @@ declare module 'vue' {
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePinnedLogsStore: UnwrapRef<typeof import('./stores/pinned')['usePinnedLogsStore']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
@@ -645,6 +660,7 @@ declare module 'vue' {
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']>
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']>
readonly useServiceStream: UnwrapRef<typeof import('./composable/eventStreams')['useServiceStream']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
@@ -652,11 +668,13 @@ declare module 'vue' {
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStackStream: UnwrapRef<typeof import('./composable/eventStreams')['useStackStream']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwarmStore: UnwrapRef<typeof import('./stores/swarm')['useSwarmStore']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
@@ -735,7 +753,6 @@ declare module '@vue/runtime-core' {
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly config: UnwrapRef<typeof import('./stores/config')['default']>
readonly containerContext: UnwrapRef<typeof import('./composable/containerContext')['containerContext']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
@@ -766,6 +783,7 @@ declare module '@vue/runtime-core' {
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
@@ -779,6 +797,7 @@ declare module '@vue/runtime-core' {
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -808,11 +827,11 @@ declare module '@vue/runtime-core' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly persistentVisibleKeys: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeys']>
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly provideLoggingContext: UnwrapRef<typeof import('./composable/logContext')['provideLoggingContext']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -895,8 +914,8 @@ declare module '@vue/runtime-core' {
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']>
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
readonly useContainerStream: UnwrapRef<typeof import('./composable/eventStreams')['useContainerStream']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
@@ -936,7 +955,9 @@ declare module '@vue/runtime-core' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
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']>
@@ -949,13 +970,14 @@ declare module '@vue/runtime-core' {
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']>
readonly useLogStream: UnwrapRef<typeof import('./composable/eventsource')['useLogStream']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMergedStream: UnwrapRef<typeof import('./composable/eventStreams')['useMergedStream']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -972,6 +994,7 @@ declare module '@vue/runtime-core' {
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePinnedLogsStore: UnwrapRef<typeof import('./stores/pinned')['usePinnedLogsStore']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
@@ -995,6 +1018,7 @@ declare module '@vue/runtime-core' {
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']>
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']>
readonly useServiceStream: UnwrapRef<typeof import('./composable/eventStreams')['useServiceStream']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
@@ -1002,11 +1026,13 @@ declare module '@vue/runtime-core' {
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStackStream: UnwrapRef<typeof import('./composable/eventStreams')['useStackStream']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwarmStore: UnwrapRef<typeof import('./stores/swarm')['useSwarmStore']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
+26 -11
View File
@@ -1,13 +1,12 @@
/* 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']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
@@ -25,33 +24,35 @@ declare module 'vue' {
'Cil:columns': typeof import('~icons/cil/columns')['default']
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerHealth: typeof import('./components/LogViewer/ContainerHealth.vue')['default']
ContainerLogViewer: typeof import('./components/LogViewer/ContainerLogViewer.vue')['default']
ContainerPopup: typeof import('./components/LogViewer/ContainerPopup.vue')['default']
ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
ContainerName: typeof import('./components/LogViewer/ContainerName.vue')['default']
ContainerPopup: typeof import('./components/ContainerPopup.vue')['default']
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default']
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
DateTime: typeof import('./components/common/DateTime.vue')['default']
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default']
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
HostList: typeof import('./components/HostList.vue')['default']
HostMenu: typeof import('./components/HostMenu.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']
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default']
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
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']
@@ -66,13 +67,22 @@ declare module 'vue' {
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerLog: typeof import('./components/MultiContainerViewer/MultiContainerLog.vue')['default']
MultiContainerStat: typeof import('./components/LogViewer/MultiContainerStat.vue')['default']
'Octicon:container24': typeof import('~icons/octicon/container24')['default']
'Octicon:download24': typeof import('~icons/octicon/download24')['default']
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
'Ph:circlesFour': typeof import('~icons/ph/circles-four')['default']
'Ph:command': typeof import('~icons/ph/command')['default']
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
'Ph:memory': typeof import('~icons/ph/memory')['default']
'Ph:stack': typeof import('~icons/ph/stack')['default']
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
Popup: typeof import('./components/Popup.vue')['default']
Releases: typeof import('./components/Releases.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
@@ -80,15 +90,20 @@ declare module 'vue' {
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default']
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default']
SidePanel: typeof import('./components/SidePanel.vue')['default']
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
SlideTransition: typeof import('./components/common/SlideTransition.vue')['default']
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
Tag: typeof import('./components/common/Tag.vue')['default']
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
Toggle: typeof import('./components/common/Toggle.vue')['default']
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
}
}
-20
View File
@@ -1,20 +0,0 @@
<template>
<div class="relative">
<div class="bar h-7 origin-left rounded-br rounded-tr bg-primary transition-transform"></div>
<div class="absolute inset-0 flex flex-col justify-center px-2 text-sm">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
const { value } = defineProps<{ value: number }>();
const minValue = computed(() => Math.min(value, 1));
</script>
<style scoped>
.bar {
transform: scaleX(v-bind(minValue));
}
</style>
@@ -2,7 +2,7 @@
<div>
<span class="font-light capitalize"> RUNNING </span>
<span class="font-semibold">
<distance-time :date="container.created" strict :suffix="false"></distance-time>
<DistanceTime :date="container.created" strict :suffix="false" />
</span>
</div>
<div>
+71 -26
View File
@@ -1,13 +1,49 @@
!
<template>
<div class="text-right" v-if="containers.length > pageSizes[0]">
Show 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 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'">
@@ -40,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>
@@ -70,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",
@@ -116,13 +161,13 @@ const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CO
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.value));
@@ -8,18 +8,16 @@
<li>
<a @click.prevent="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<key-shortcut char="k" :modifiers="['shift', 'meta']"></key-shortcut>
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</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">
<mdi:magnify /> {{ $t("toolbar.search") }}
<key-shortcut char="f"></key-shortcut>
<KeyShortcut char="f" />
</a>
</li>
<li class="line"></li>
@@ -103,15 +101,30 @@
</template>
<script lang="ts" setup>
import { Container } from "@/models/Container";
const { showSearch } = useSearchFilter();
const { base, enableActions } = config;
const { enableActions } = config;
const clear = defineEmit();
const { container, streamConfig } = useContainerContext();
const { streamConfig } = useLoggingContext();
// container context is provided in the parent component: <LogContainer>
const { actionStates, start, stop, restart } = useContainerActions();
const { container } = defineProps<{ container: Container }>();
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
const downloadParams = computed(() =>
Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
);
const downloadUrl = computed(() =>
withBase(
`/api/hosts/${container.host}/containers/${container.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
),
);
const disableRestart = computed(() => {
return actionStates.stop || actionStates.start || actionStates.restart;
@@ -0,0 +1,52 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="container">
<template #header v-if="showTitle">
<div class="mx-2 flex items-center gap-2 md:ml-4">
<ContainerTitle :container="container" />
<MultiContainerStat class="ml-auto" :containers="[container]" />
<ContainerActionsToolbar @clear="viewer?.clear()" class="mobile-hidden" :container="container" />
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
<mdi:close />
</a>
</div>
</template>
<template #default="{ setLoading }">
<ViewerWithSource
ref="viewer"
@loading-more="setLoading($event)"
:stream-source="useContainerStream"
:entity="container"
:visible-keys="visibleKeys"
:show-container-name="false"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
import { ComponentExposed } from "vue-component-type-helpers";
const {
id,
showTitle = false,
scrollable = false,
closable = false,
} = defineProps<{
id: string;
showTitle?: boolean;
scrollable?: boolean;
closable?: boolean;
}>();
const close = defineEmit();
const store = useContainerStore();
const container = store.currentContainer($$(id));
const visibleKeys = persistentVisibleKeysForContainer(container);
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
provideLoggingContext(toRef(() => [container.value]));
</script>
@@ -17,22 +17,24 @@
{{ container.swarmId }}
</div>
</div>
<container-health :health="container.health" v-if="container.health"></container-health>
<tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
<ContainerHealth :health="container.health" v-if="container.health" />
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
{{ container.image.replace(/@sha.*/, "") }}
</tag>
</Tag>
</div>
</template>
<script lang="ts" setup>
const { container } = useContainerContext();
import { Container } from "@/models/Container";
const { container } = defineProps<{ container: Container }>();
const pinned = computed({
get: () => pinnedContainers.value.has(container.value.name),
get: () => pinnedContainers.value.has(container.name),
set: (value) => {
if (value) {
pinnedContainers.value.add(container.value.name);
pinnedContainers.value.add(container.name);
} else {
pinnedContainers.value.delete(container.value.name);
pinnedContainers.value.delete(container.name);
}
},
});
+7 -6
View File
@@ -32,7 +32,7 @@
</template>
<span data-name v-html="matchedName(result)"></span>
</div>
<distance-time :date="result.item.created" class="text-xs font-light" />
<DistanceTime :date="result.item.created" class="text-xs font-light" />
<a
@click.stop.prevent="addColumn(result.item)"
:title="$t('tooltip.pin-column')"
@@ -62,8 +62,9 @@ const input = ref<HTMLInputElement>();
const selectedIndex = ref(0);
const router = useRouter();
const store = useContainerStore();
const { containers } = storeToRefs(store);
const containerStore = useContainerStore();
const pinnedStore = usePinnedLogsStore();
const { containers } = storeToRefs(containerStore);
const list = computed(() => {
return containers.value.map(({ id, created, name, state, labels, hostLabel: host }) => {
@@ -91,8 +92,8 @@ 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 === b.item.state) {
return b.item.created - a.item.created;
@@ -121,7 +122,7 @@ function selected({ id }: { id: string }) {
close();
}
function addColumn(container: { id: string }) {
store.appendActiveContainer(container);
pinnedStore.pinContainer(container);
close();
}
@@ -0,0 +1,46 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="group.containers.length && ready">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ group.containers.length }} containers</div>
</div>
</div>
<MultiContainerStat class="ml-auto" :containers="group.containers" />
</div>
</template>
<template #default="{ setLoading }">
<ViewerWithSource
ref="viewer"
@loading-more="setLoading($event)"
:stream-source="useGroupedStream"
:entity="group"
:visible-keys="visibleKeys"
:show-container-name="true"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
import { GroupedContainers } from "@/models/Container";
const { name, scrollable = false } = defineProps<{
name: string;
scrollable?: boolean;
}>();
const containerStore = useContainerStore();
const { ready } = storeToRefs(containerStore);
const swarmStore = useSwarmStore();
const { customGroups } = storeToRefs(swarmStore);
const group = computed(() => customGroups.value.find((g) => g.name === name) ?? new GroupedContainers("", []));
const visibleKeys = ref<string[][]>([]);
provideLoggingContext(toRef(() => group.value.containers));
</script>
+99
View File
@@ -0,0 +1,99 @@
<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 }} <span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</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>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div class="breadcrumbs">
<ul>
<li><a @click.prevent="setHost(null)" class="link-primary">Hosts</a></li>
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
{{ hosts[sessionHost].name }}
</li>
</ul>
</div>
<SlideTransition :slide-right="!!sessionHost">
<template #left>
<ul class="menu p-0">
<li v-for="host in hosts" :key="host.id">
<a @click.prevent="setHost(host.id)" :class="{ '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>
</template>
<template #right>
<ul class="containers menu p-0 [&_li.menu-title]:px-0">
<li v-for="{ label, containers, icon } in menuItems" :key="label">
<!-- @vue-ignore -->
<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 }}
<router-link
:to="{
name: 'merged-name',
query: { id: containers.map(({ id }) => id) },
params: { name: label.replace('label.', '') },
}"
class="btn btn-square btn-outline btn-primary btn-xs"
active-class="btn-active"
title="Merge all containers into one view"
>
<ph:arrows-merge />
</router-link>
</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="pinnedStore.pinContainer(item)"
:title="item.name"
class="group auto-cols-[auto_max-content_max-content]"
>
<div class="truncate">
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">.{{ item.swarmId }}</span>
</div>
<ContainerHealth :health="item.health" />
<span
class="hidden hover:text-secondary group-hover:inline-block"
@click.stop.prevent="pinnedStore.pinContainer(item)"
v-show="!pinnedStore.isPinned(item)"
:title="$t('tooltip.pin-column')"
>
<cil:columns />
</span>
</router-link>
<template #content>
<ContainerPopup :container="item" />
</template>
</Popup>
</li>
</ul>
</details>
</li>
</ul>
</template>
</SlideTransition>
</template>
<script lang="ts" setup>
import { Container } from "@/models/Container";
import { sessionHost } from "@/composable/storage";
// @ts-ignore
import Pin from "~icons/ph/map-pin-simple";
// @ts-ignore
import Stack from "~icons/ph/stack";
// @ts-ignore
import Containers from "~icons/octicon/container-24";
const containerStore = useContainerStore();
const { visibleContainers } = storeToRefs(containerStore);
const pinnedStore = usePinnedLogsStore();
const { hosts } = useHosts();
const setHost = (host: string | null) => (sessionHost.value = host);
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(sorter),
);
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(() => {
const namespaced: Record<string, Container[]> = {};
const pinned = [];
const singular = [];
for (const item of sortedContainers.value) {
const namespace = item.namespace;
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;
});
</script>
<style scoped lang="postcss">
.menu {
@apply text-[0.95rem];
}
li.exited {
@apply opacity-50;
}
</style>
+1
View File
@@ -14,6 +14,7 @@ const isLoading = ref(false);
const root = ref<HTMLElement>();
const observer = new IntersectionObserver(async (entries) => {
// console.log(entries, entries[0].intersectionRatio);
if (entries[0].intersectionRatio <= 0) return;
if (onLoadMore && enabled) {
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
+3 -3
View File
@@ -1,6 +1,6 @@
<template>
<div class="flex items-center justify-end gap-4">
<dropdown class="dropdown-end" @closed="latestTag = latest?.tag ?? config.version">
<Dropdown class="dropdown-end" @closed="latestTag = latest?.tag ?? config.version">
<template #trigger>
<mdi:announcement class="size-6 -rotate-12" />
<span
@@ -10,10 +10,10 @@
</template>
<template #content>
<div class="w-72">
<releases />
<Releases />
</div>
</template>
</dropdown>
</Dropdown>
<router-link
:to="{ name: 'settings' }"
+11 -7
View File
@@ -5,18 +5,21 @@
<material-symbols:expand-all-rounded class="swap-off text-secondary" />
<material-symbols:collapse-all-rounded class="swap-on text-secondary" />
</label>
<div v-if="showContainerName">
<ContainerName :id="logEntry.containerID" />
</div>
<div v-if="showStd">
<log-std :std="logEntry.std"></log-std>
<LogStd :std="logEntry.std" />
</div>
<div v-if="showTimestamp">
<log-date :date="logEntry.date"></log-date>
<LogDate :date="logEntry.date" />
</div>
<div class="flex">
<log-level :level="logEntry.level"></log-level>
<LogLevel :level="logEntry.level" />
</div>
<div>
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }">
<li v-for="(value, name) in validValues">
<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>
@@ -25,9 +28,9 @@
</li>
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
</ul>
<field-list :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
<FieldList :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys" />
</div>
<log-message-actions
<LogMessageActions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
:message="() => JSON.stringify(logEntry.message)"
:log-entry="logEntry"
@@ -39,9 +42,10 @@ import { type ComplexLogEntry } from "@/models/LogEntry";
const { markSearch } = useSearchFilter();
const { logEntry } = defineProps<{
const { logEntry, showContainerName = false } = defineProps<{
logEntry: ComplexLogEntry;
visibleKeys: string[][];
showContainerName?: boolean;
}>();
const [expanded, expandToggle] = useToggle();
@@ -1,39 +0,0 @@
<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>
@@ -0,0 +1,41 @@
<template>
<div class="grid w-40 overflow-hidden rounded text-center text-sm text-white">
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
<div class="col-start-1 row-start-1 truncate px-2 brightness-100 [direction:rtl]">{{ containerNames[id] }}</div>
</div>
</template>
<script lang="ts">
const colors = [
"#4B0082",
"#FF00FF",
"#FF7F00",
"#FFFF00",
"#00FF00",
"#00FFFF",
"#FF0000",
"#0000FF",
"#FF007F",
"#32CD32",
"#40E0D0",
"#E6E6FA",
"#800080",
"#FFD700",
"#FF4040",
] as const;
</script>
<script lang="ts" setup>
const containerStore = useContainerStore();
const { containerNames } = storeToRefs(containerStore);
const { id } = defineProps<{
id: string;
}>();
const color = computed(() => colors[Math.abs(hashCode(id)) % colors.length]);
</script>
<style lang="postcss" scoped>
.random-color {
background-color: v-bind(color);
}
</style>
@@ -1,36 +0,0 @@
<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).toFixed(2) + '%'" />
</div>
</template>
<script lang="ts" setup>
const { container } = useContainerContext();
const cpuData = computedWithControl(
() => container.value.stat,
() => {
const history = container.value.statsHistory;
const points: Point<unknown>[] = history.map((stat, i) => ({
x: i,
y: Math.max(0, stat.cpu),
value: Math.max(0, stat.cpu).toFixed(2) + "%",
}));
return points;
},
);
const memoryData = computedWithControl(
() => container.value.stat,
() => {
const history = container.value.statsHistory;
const points: Point<string>[] = history.map((stat, i) => ({
x: i,
y: stat.memory,
value: formatBytes(stat.memoryUsage),
}));
return points;
},
);
</script>
@@ -1,10 +1,12 @@
<template>
<div class="flex-1 font-sans text-[0.9rem]">
<span class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></span>
<div
class="alert alert-info mt-8 w-auto text-[1rem] md:mx-auto md:w-1/2"
v-if="nextContainer && logEntry.event === 'container-stopped'"
>
<div class="flex w-full flex-col">
<div class="relative flex items-start gap-x-2">
<ContainerName class="flex-none" :id="logEntry.containerID" v-if="showContainerName" />
<LogDate :date="logEntry.date" v-if="showTimestamp" />
<LogLevel class="flex" />
<div class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></div>
</div>
<div class="alert alert-info mt-8 w-auto flex-none font-sans text-[1rem] md:mx-auto md:w-1/2" v-if="followEligible">
<carbon:information class="size-6 shrink-0 stroke-current" />
<div>
<h3 class="text-lg font-bold">{{ $t("alert.similar-container-found.title") }}</h3>
@@ -31,35 +33,46 @@ const { t } = useI18n();
const { logEntry } = defineProps<{
logEntry: DockerEventLogEntry;
showContainerName?: boolean;
}>();
const { containers } = useLoggingContext();
const store = useContainerStore();
const { containers } = storeToRefs(store);
const { container } = useContainerContext();
const { containers: allContainers } = storeToRefs(store);
const nextContainer = computed(
() =>
containers.value
.filter(
[
...allContainers.value.filter(
(c) =>
c.host === container.value.host &&
c.host === containers.value[0].host &&
c.created > logEntry.date &&
c.name === container.value.name &&
c.name === containers.value[0].name &&
c.state === "running",
)
.toSorted((a, b) => +a.created - +b.created)[0],
),
].sort((a, b) => +a.created - +b.created)[0],
);
const followEligible = computed(
() =>
router.currentRoute.value.name === "container-id" &&
logEntry.event === "container-stopped" &&
containers.value.length === 1 &&
nextContainer.value !== undefined,
);
function redirectNow() {
showToast(
{
title: t("alert.redirected.title"),
message: t("alert.redirected.message", { containerId: nextContainer.value.id }),
message: t("alert.redirected.message", { containerId: nextContainer.value?.id }),
type: "info",
},
{ expire: 5000 },
);
router.push({ name: "container-id", params: { id: nextContainer.value.id } });
router.push({ name: "container-id", params: { id: nextContainer.value?.id } });
}
</script>
@@ -1,6 +1,5 @@
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { containerContext } from "@/composable/containerContext";
import { useSearchFilter } from "@/composable/search";
import { settings } from "@/stores/settings";
// @ts-ignore
@@ -9,8 +8,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { computed, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router";
import LogEventSource from "./LogEventSource.vue";
import ContainerLogViewer from "./ContainerLogViewer.vue";
import { default as Component } from "./EventSource.vue";
import LogViewer from "@/components/LogViewer/LogViewer.vue";
import { Container } from "@/models/Container";
vi.mock("@/stores/config", () => ({
__esModule: true,
@@ -21,7 +21,7 @@ vi.mock("@/stores/config", () => ({
/**
* @vitest-environment jsdom
*/
describe("<LogEventSource />", () => {
describe("<ContainerEventSource />", () => {
const search = useSearchFilter();
beforeEach(() => {
@@ -67,32 +67,35 @@ describe("<LogEventSource />", () => {
],
});
return mount(LogEventSource, {
return mount(Component<Container>, {
global: {
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
components: {
ContainerLogViewer,
LogViewer,
},
provide: {
[containerContext as symbol]: {
container: computed(() => ({ id: "abc", image: "test:v123", host: "localhost" })),
scrollingPaused: computed(() => false),
[loggingContextKey as symbol]: {
containers: computed(() => [{ id: "abc", image: "test:v123", host: "localhost" }]),
streamConfig: reactive({ stdout: true, stderr: true }),
},
scrollingPaused: computed(() => false),
},
},
slots: {
default: `
<template #scoped="params"><container-log-viewer :messages="params.messages" /></template>
<template #scoped="params"><LogViewer :messages="params.messages" :show-container-name="false" :visible-keys="[]" /></template>
`,
},
props: {},
props: {
streamSource: useContainerStream,
entity: new Container("abc", new Date(), "image", "name", "command", "localhost", {}, "status", "created", []),
},
});
}
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 () => {
test("renders loading correctly", async () => {
const wrapper = createLogEventSource();
expect(wrapper.html()).toMatchSnapshot();
});
@@ -0,0 +1,28 @@
<template>
<InfiniteLoader :onLoadMore="fetchMore" :enabled="messages.length > 50"></InfiniteLoader>
<slot :messages="messages"></slot>
</template>
<script lang="ts" setup generic="T">
import { LogStreamSource } from "@/composable/eventStreams";
const loadingMore = defineEmit<[value: boolean]>();
const props = defineProps<{
streamSource: (t: Ref<T>) => LogStreamSource;
entity: T;
}>();
const { entity, streamSource } = toRefs(props);
const { messages, loadOlderLogs } = streamSource.value(entity);
const beforeLoading = () => loadingMore(true);
const afterLoading = () => loadingMore(false);
defineExpose({
clear: () => (messages.value = []),
});
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
</script>
+2 -7
View File
@@ -1,14 +1,9 @@
<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
:fields="value"
:parent-key="parentKey.concat(name)"
:visible-keys="visibleKeys"
expanded
></field-list>
<FieldList :fields="value" :parent-key="parentKey.concat(name)" :visible-keys="visibleKeys" expanded />
</template>
<template v-else-if="Array.isArray(value)">
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">
@@ -1,53 +0,0 @@
<template>
<scrollable-view :scrollable="scrollable" v-if="container">
<template #header v-if="showTitle">
<div class="mx-2 flex items-center gap-2 md:ml-4">
<container-title @close="$emit('close')" />
<container-stat class="ml-auto" />
<log-actions-toolbar @clear="onClearClicked()" class="mobile-hidden" />
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
<mdi:close />
</a>
</div>
</template>
<template #default="{ setLoading }">
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
</template>
</scrollable-view>
</template>
<script lang="ts" setup>
import LogViewerWithSource from "./LogViewerWithSource.vue";
const {
id,
showTitle = false,
scrollable = false,
closable = false,
} = defineProps<{
id: string;
showTitle?: boolean;
scrollable?: boolean;
closable?: boolean;
}>();
const close = defineEmit();
const store = useContainerStore();
const container = store.currentContainer($$(id));
provideContainerContext(container);
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
function onClearClicked() {
viewer.value?.clear();
}
onKeyStroke("k", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
onClearClicked();
e.preventDefault();
}
});
</script>
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<tag size="small">
<date-time :date="date" class="whitespace-nowrap text-blue"></date-time>
</tag>
<Tag size="small">
<DateTime :date="date" class="whitespace-nowrap text-blue" />
</Tag>
</template>
<script lang="ts" setup>
defineProps<{
@@ -1,19 +0,0 @@
<template>
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
<slot :messages="messages"></slot>
</template>
<script lang="ts" setup>
const loadingMore = defineEmit<[value: boolean]>();
const { messages, loadOlderLogs } = useLogStream();
const beforeLoading = () => loadingMore(true);
const afterLoading = () => loadingMore(false);
defineExpose({
clear: () => (messages.value = []),
});
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
</script>
+80
View File
@@ -0,0 +1,80 @@
<template>
<ul
class="events group py-4"
:class="{ 'disable-wrap': !softWrap, [size]: true, compact }"
v-if="messages.length > 0"
>
<li
v-for="item in messages"
:key="item.id"
:data-key="item.id"
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
class="group/entry"
>
<component
:is="item.getComponent()"
:log-entry="item"
:visible-keys="visibleKeys"
:show-container-name="showContainerName"
/>
</li>
</ul>
<div v-else class="m-4 text-center">
<span class="loading loading-ring loading-md text-primary"></span>
</div>
</template>
<script lang="ts" setup>
import { toRaw } from "vue";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
defineProps<{
messages: LogEntry<string | JSONObject>[];
visibleKeys: string[][];
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
showContainerName: boolean;
}>();
</script>
<style scoped lang="postcss">
.events {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Consolas,
Liberation Mono,
monaco,
Menlo,
monospace;
> li {
@apply flex break-words px-2 py-1 last:snap-end odd:bg-gray-400/[0.07] md:px-4;
&:last-child {
scroll-margin-block-end: 5rem;
}
.jump-context {
@apply mr-2 flex items-center font-sans text-secondary;
}
}
&.small {
@apply text-[0.7em];
}
&.medium {
@apply text-[0.8em];
}
&.large {
@apply text-lg;
}
&.compact {
> li {
@apply py-0;
}
}
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<tag size="small" :std="std">
<Tag size="small" :std="std">
{{ std }}
</tag>
</Tag>
</template>
<script lang="ts" setup>
+32 -56
View File
@@ -1,67 +1,43 @@
<template>
<ul class="events group py-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
<li
v-for="item in messages"
:key="item.id"
:data-key="item.id"
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
class="group/entry"
>
<component :is="item.getComponent()" :log-entry="item" :visible-keys="visibleKeys" />
</li>
</ul>
<LogList
:messages="filtered"
:last-selected-item="lastSelectedItem"
:visible-keys="visibleKeys"
:show-container-name="showContainerName"
/>
</template>
<script lang="ts" setup>
import { toRaw } from "vue";
import { useRouteHash } from "@vueuse/router";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
defineProps<{
const props = defineProps<{
messages: LogEntry<string | JSONObject>[];
visibleKeys: string[][];
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
showContainerName: boolean;
}>();
const { messages, visibleKeys } = toRefs(props);
const { filteredPayload } = useVisibleFilter(visibleKeys);
const { filteredMessages } = useSearchFilter();
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">
.events {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Consolas,
Liberation Mono,
monaco,
Menlo,
monospace;
> li {
@apply flex break-words px-2 py-1 last:snap-end odd:bg-gray-400/[0.07] md:px-4;
&:last-child {
scroll-margin-block-end: 5rem;
}
.jump-context {
@apply mr-2 flex items-center font-sans text-secondary;
}
}
&.small {
@apply text-[0.7em];
}
&.medium {
@apply text-[0.8em];
}
&.large {
@apply text-lg;
}
&.compact {
> li {
@apply py-0;
}
}
}
</style>
<style scoped lang="postcss"></style>
@@ -1,18 +0,0 @@
<template>
<log-event-source ref="source" #default="{ messages }" @loading-more="loadingMore($event)">
<container-log-viewer :messages="messages" />
</log-event-source>
</template>
<script lang="ts" setup>
import LogEventSource from "./LogEventSource.vue";
const loadingMore = defineEmit<[value: boolean]>();
const source = $ref<InstanceType<typeof LogEventSource>>();
function clear() {
source?.clear();
}
defineExpose({
clear,
});
</script>
@@ -0,0 +1,73 @@
<template>
<div class="flex gap-4">
<StatMonitor :data="memoryData" label="mem" :stat-value="formatBytes(totalStat.memoryUsage)" />
<StatMonitor :data="cpuData" label="load" :stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'" />
</div>
</template>
<script lang="ts" setup>
import { Stat } from "@/models/Container";
import { Container } from "@/models/Container";
const { containers } = defineProps<{
containers: Container[];
}>();
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
watch(
() => containers,
() => {
const initial: Stat[] = [];
for (let i = 1; i <= 300; i++) {
const stat = containers.reduce(
(acc, { statsHistory }) => {
const item = statsHistory.at(-i);
if (!item) {
return acc;
}
return {
cpu: acc.cpu + item.cpu,
memory: acc.memory + item.memory,
memoryUsage: acc.memoryUsage + item.memoryUsage,
};
},
{ cpu: 0, memory: 0, memoryUsage: 0 },
);
initial.push(stat);
}
reset({ initial: initial.reverse() });
},
{ immediate: true },
);
useIntervalFn(() => {
totalStat.value = containers.reduce(
(acc, { stat }) => {
return {
cpu: acc.cpu + stat.cpu,
memory: acc.memory + stat.memory,
memoryUsage: acc.memoryUsage + stat.memoryUsage,
};
},
{ cpu: 0, memory: 0, memoryUsage: 0 },
);
}, 1000);
const cpuData = computed(() =>
history.value.map((stat, i) => ({
x: i,
y: Math.max(0, stat.cpu),
value: Math.max(0, stat.cpu).toFixed(2) + "%",
})),
);
const memoryData = computed(() =>
history.value.map((stat, i) => ({
x: i,
y: stat.memoryUsage,
value: formatBytes(stat.memoryUsage),
})),
);
</script>
+12 -6
View File
@@ -1,13 +1,14 @@
<template>
<div class="relative flex w-full items-start gap-x-2">
<log-std :std="logEntry.std" v-if="showStd" />
<log-date :date="logEntry.date" v-if="showTimestamp" />
<log-level class="flex" :level="logEntry.level" :position="logEntry.position" />
<LogStd :std="logEntry.std" v-if="showStd" />
<ContainerName class="flex-none" :id="logEntry.containerID" v-if="showContainerName" />
<LogDate :date="logEntry.date" v-if="showTimestamp" />
<LogLevel class="flex" :level="logEntry.level" :position="logEntry.position" />
<div
class="whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap"
v-html="colorize(logEntry.message)"
></div>
<log-message-actions
<LogMessageActions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
:message="() => decodeXML(stripAnsi(logEntry.message))"
:log-entry="logEntry"
@@ -20,10 +21,15 @@ 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)" });
const ansiConvertor = new AnsiConvertor({
escapeXML: false,
fg: "var(--base-content-color)",
bg: "var(--base-color)",
});
defineProps<{
const { showContainerName = false } = defineProps<{
logEntry: SimpleLogEntry;
showContainerName?: boolean;
}>();
const { markSearch } = useSearchFilter();
@@ -1,10 +1,10 @@
<template>
<div class="my-4 flex-1 text-center">
<div class="relative">
<zig-zag class="absolute inset-0 mt-2"></zig-zag>
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">{{
$t("error.logs-skipped", { total: logEntry.totalSkipped })
}}</span>
<ZigZag class="absolute inset-0 mt-2" />
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">
{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
</span>
</div>
</div>
</template>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="relative hover:text-secondary" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
<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>
<StatSparkline :data="data" @selected-point="onSelectedPoint" />
</div>
<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>
@@ -0,0 +1,38 @@
<template>
<EventSource
ref="source"
#default="{ messages }"
@loading-more="loadingMore($event)"
:stream-source="streamSource"
:entity="props.entity"
>
<LogViewer :messages="messages" :visible-keys="visibleKeys" :show-container-name="showContainerName" />
</EventSource>
</template>
<script lang="ts" setup generic="T">
import LogEventSource from "@/components/ContainerViewer/LogEventSource.vue";
import { LogStreamSource } from "@/composable/eventStreams";
const props = defineProps<{
streamSource: (t: Ref<T>) => LogStreamSource;
visibleKeys: string[][];
showContainerName: boolean;
entity: T;
}>();
const loadingMore = defineEmit<[value: boolean]>();
const source = $ref<InstanceType<typeof LogEventSource>>();
defineExpose({
clear: () => source?.clear(),
});
onKeyStroke("k", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
source?.clear();
e.preventDefault();
}
});
</script>
@@ -1,11 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
"<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" size="small">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
@@ -19,12 +20,13 @@ exports[`<LogEventSource /> > render html correctly > should render dates with 1
</ul>"
`;
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
"<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" size="small">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
@@ -38,12 +40,13 @@ exports[`<LogEventSource /> > render html correctly > should render dates with 2
</ul>"
`;
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
"<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" size="small">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
@@ -57,12 +60,13 @@ exports[`<LogEventSource /> > render html correctly > should render messages 1`]
</ul>"
`;
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="2" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
exports[`<ContainerEventSource /> > render html correctly > should render messages with filter 1`] = `
"<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-cf9ff940="" data-key="2" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" size="small">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
@@ -78,12 +82,13 @@ exports[`<LogEventSource /> > render html correctly > should render messages wit
</ul>"
`;
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
exports[`<ContainerEventSource /> > render html correctly > should render messages with html entities 1`] = `
"<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
<!--v-if-->
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" size="small">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
@@ -97,14 +102,15 @@ exports[`<LogEventSource /> > render html correctly > should render messages wit
</ul>"
`;
exports[`<LogEventSource /> > renders correctly 1`] = `
exports[`<ContainerEventSource /> > renders loading correctly 1`] = `
"<div class="flex min-h-[1px] justify-center"><span class="loading loading-bars loading-md mt-4 text-primary" style="display: none;"></span></div>
<ul data-v-2e92daca="" class="events group py-4 medium"></ul>"
<div data-v-cf9ff940="" class="m-4 text-center"><span data-v-cf9ff940="" class="loading loading-ring loading-md text-primary"></span></div>"
`;
exports[`<LogEventSource /> > should parse messages 1`] = `
exports[`<ContainerEventSource /> > should parse messages 1`] = `
SimpleLogEntry {
"_message": "This is a message.",
"containerID": undefined,
"date": 2019-06-12T10:55:42.459Z,
"id": 1,
"level": undefined,
@@ -0,0 +1,43 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="containers.length && ready">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ containers.length }} containers</div>
</div>
</div>
<MultiContainerStat class="ml-auto" :containers="containers" />
</div>
</template>
<template #default="{ setLoading }">
<ViewerWithSource
ref="viewer"
@loading-more="setLoading($event)"
:stream-source="useMergedStream"
:entity="containers"
:visible-keys="visibleKeys"
:show-container-name="true"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
const { ids = [], scrollable = false } = defineProps<{
ids?: string[];
scrollable?: boolean;
}>();
const containerStore = useContainerStore();
const { allContainersById, ready } = storeToRefs(containerStore);
const containers = computed(() => ids.map((id) => allContainersById.value[id]));
const visibleKeys = ref<string[][]>([]);
provideLoggingContext(containers);
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-8 px-4 py-4 md:px-8">
<section>
<links />
<Links />
</section>
<slot></slot>
</div>
+4 -5
View File
@@ -16,11 +16,10 @@
<script lang="ts" setup>
import { globalShowPopup } from "@/composable/popup";
let glopbalShow = globalShowPopup();
let show = ref(glopbalShow.value);
let delayedShow = refDebounced(show, 1000);
let content = ref<HTMLElement>();
const glopbalShow = globalShowPopup();
const show = ref(glopbalShow.value);
const delayedShow = refDebounced(show, 1000);
const content = ref<HTMLElement>();
const onMouseEnter = (e: Event) => {
show.value = true;
+2 -2
View File
@@ -7,9 +7,9 @@
{{ release.name }}
</a>
<span class="ml-1 text-xs"><distance-time :date="new Date(release.createdAt)" /></span>
<tag class="ml-auto bg-red px-1 py-1 text-xs" v-if="release.tag === latest?.tag">
<Tag class="ml-auto bg-red px-1 py-1 text-xs" v-if="release.tag === latest?.tag">
{{ $t("releases.latest") }}
</tag>
</Tag>
</div>
<div class="text-sm text-base-content/80">
{{ summary(release) }}
+5 -3
View File
@@ -27,15 +27,17 @@ const { indeterminate = false, autoHide = false } = defineProps<{
const scrollProgress = ref(0);
const root = ref<HTMLElement>();
const store = useContainerStore();
const { activeContainers } = storeToRefs(store);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const scrollElement = ref<HTMLElement | Document>((root.value?.closest("[data-scrolling]") as HTMLElement) ?? document);
const { y: scrollY } = useScroll(scrollElement as Ref<HTMLElement | Document>, { throttle: 100 });
const show = autoResetRef(false, 2000);
onMounted(() => {
watch(
activeContainers,
pinnedLogs,
() => {
scrollElement.value = (root.value?.closest("[data-scrolling]") as HTMLElement) ?? document;
},
+1 -1
View File
@@ -8,7 +8,7 @@
</header>
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
<div class="invisible mr-28 text-right md:visible" v-show="paused">
<scroll-progress :indeterminate="loading" :auto-hide="!loading" class="!fixed top-16 z-10" />
<ScrollProgress :indeterminate="loading" :auto-hide="!loading" class="!fixed top-16 z-10" />
</div>
<div ref="scrollableContent">
<slot :setLoading="setLoading"></slot>
@@ -0,0 +1,45 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="service.name">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ service.name }}</div>
</div>
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
{{ $t("label.container", service.containers.length) }}
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="service.containers" />
</div>
</template>
<template #default="{ setLoading }">
<ViewerWithSource
ref="viewer"
@loading-more="setLoading($event)"
:stream-source="useServiceStream"
:entity="service"
:visible-keys="visibleKeys"
:show-container-name="true"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import { Service } from "@/models/Stack";
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
const { name, scrollable = false } = defineProps<{
scrollable?: boolean;
name: string;
}>();
const visibleKeys = ref<string[][]>([]);
const store = useSwarmStore();
const { services } = storeToRefs(store) as unknown as { services: Ref<Service[]> };
const service = computed(() => services.value.find((s) => s.name === name) ?? new Service("", []));
provideLoggingContext(toRef(() => service.value.containers));
</script>
+18 -192
View File
@@ -1,59 +1,17 @@
<template>
<div v-if="ready" data-testid="side-menu">
<div class="breadcrumbs">
<ul>
<li><a @click.prevent="setHost(null)" class="link-primary">Hosts</a></li>
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
{{ hosts[sessionHost].name }}
</li>
</ul>
</div>
<transition :name="sessionHost ? 'slide-left' : 'slide-right'" mode="out-in">
<ul class="menu p-0" v-if="!sessionHost">
<li v-for="host in config.hosts">
<a @click.prevent="setHost(host.id)">
<ph:computer-tower />
{{ host.name }}
</a>
</li>
</ul>
<transition-group tag="ul" name="list" class="containers menu p-0 [&_li.menu-title]:px-0" v-else>
<li
v-for="item in menuItems"
:key="isContainer(item) ? item.id : item.keyLabel"
:class="isContainer(item) ? item.state : 'menu-title'"
:data-testid="isContainer(item) ? null : item.keyLabel"
>
<popup v-if="isContainer(item)">
<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>
<template v-else>
{{ $t(item.keyLabel) }}
</template>
</li>
</transition-group>
</transition>
<Toggle v-model="showSwarm" v-if="services.length > 0 || customGroups.length > 0">
<div class="text-lg font-light">{{ $t("label.swarm-mode") }}</div>
</Toggle>
<SlideTransition :slide-right="showSwarm">
<template #left>
<HostMenu />
</template>
<template #right>
<SwarmMenu />
</template>
</SlideTransition>
</div>
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
<div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
@@ -62,144 +20,12 @@
</template>
<script lang="ts" setup>
import { Container } from "@/models/Container";
import { sessionHost } from "@/composable/storage";
const containerStore = useContainerStore();
const { ready } = storeToRefs(containerStore);
const store = useContainerStore();
const swarmStore = useSwarmStore();
const { services, customGroups } = storeToRefs(swarmStore);
const { activeContainers, visibleContainers, ready } = storeToRefs(store);
function setHost(host: string | null) {
sessionHost.value = host;
}
const debouncedIds = debouncedRef(pinnedContainers, 200);
const sortedContainers = computed(() =>
visibleContainers.value
.filter((c) => c.host === sessionHost.value)
.sort((a, b) => {
if (a.state === "running" && b.state !== "running") {
return -1;
} else if (a.state !== "running" && b.state === "running") {
return 1;
} else {
return a.name.localeCompare(b.name);
}
}),
);
const groupedContainers = computed(() =>
sortedContainers.value.reduce(
(acc, item) => {
if (debouncedIds.value.has(item.name)) {
acc.pinned.push(item);
} else {
acc.unpinned.push(item);
}
return acc;
},
{ pinned: [] as Container[], unpinned: [] as Container[] },
),
);
function isContainer(item: any): item is Container {
return item.hasOwnProperty("image");
}
const menuItems = computed(() => {
const pinnedLabel = { keyLabel: "label.pinned" };
const allLabel = { keyLabel: showAllContainers.value ? "label.all-containers" : "label.running-containers" };
if (groupedContainers.value.pinned.length > 0) {
return [pinnedLabel, ...groupedContainers.value.pinned, allLabel, ...groupedContainers.value.unpinned];
} else {
return [allLabel, ...groupedContainers.value.unpinned];
}
});
const hosts = computed(() =>
config.hosts.reduce(
(acc, item) => {
acc[item.id] = item;
return acc;
},
{} as Record<string, { name: string; id: string }>,
),
);
const activeContainersById = computed(() =>
activeContainers.value.reduce(
(acc, item) => {
acc[item.id] = item;
return acc;
},
{} as Record<string, Container>,
),
);
const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
</script>
<style scoped lang="postcss">
.menu {
@apply text-[0.95rem];
}
.containers a {
@apply auto-cols-[auto_max-content_max-content];
.pin {
display: none;
&:hover {
@apply text-secondary;
}
}
&:hover {
.pin {
display: inline-block;
}
}
}
li.exited {
@apply opacity-50;
}
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.1s ease-out;
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(20px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(20px);
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.19s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
}
</style>
<style scoped lang="postcss"></style>
+5 -4
View File
@@ -12,15 +12,16 @@
</small>
</h1>
<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>
@@ -0,0 +1,47 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="stack.name">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ stack.name }}</div>
</div>
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
{{ $t("label.container", stack.containers.length) }}
</Tag>
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
{{ $t("label.serivce", stack.services.length) }}
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
</div>
</template>
<template #default="{ setLoading }">
<ViewerWithSource
ref="viewer"
@loading-more="setLoading($event)"
:stream-source="useStackStream"
:entity="stack"
:visible-keys="visibleKeys"
:show-container-name="true"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import { Stack } from "@/models/Stack";
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
const { name, scrollable = false } = defineProps<{
scrollable?: boolean;
name: string;
}>();
const visibleKeys = ref<string[][]>([]);
const store = useSwarmStore();
const { stacks } = storeToRefs(store) as unknown as { stacks: Ref<Stack[]> };
const stack = computed(() => stacks.value.find((s) => s.name === name) ?? new Stack("", [], []));
provideLoggingContext(toRef(() => stack.value.containers));
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<ul class="menu p-0 text-[0.95rem]">
<li v-for="{ name, services } in stacks" :key="name">
<details open>
<summary class="font-light text-base-content/80">
<ph:stack />
{{ name }}
<router-link
:to="{ name: 'stack-name', params: { name } }"
class="btn btn-square btn-outline btn-primary btn-xs"
active-class="btn-active"
title="Merge all services into one view"
>
<ph:arrows-merge />
</router-link>
</summary>
<ul>
<li v-for="service in services" :key="service.name">
<router-link :to="{ name: 'service-name', params: { name: service.name } }" active-class="active-primary">
<ph:stack-simple />
<div class="truncate">
{{ service.name }}
</div>
</router-link>
</li>
</ul>
</details>
</li>
<li v-if="serivcesWithoutStacks.length > 0">
<details open>
<summary class="font-light text-base-content/80">
<ph:circles-four />
{{ $t("label.services") }}
</summary>
<ul>
<li v-for="service in serivcesWithoutStacks" :key="service.name">
<router-link :to="{ name: 'service-name', params: { name: service.name } }" active-class="active-primary">
<ph:stack-simple />
<div class="truncate">
{{ service.name }}
</div>
</router-link>
</li>
</ul>
</details>
</li>
<li v-if="customGroups.length > 0">
<details open>
<summary class="font-light text-base-content/80">
<ph:bounding-box-fill />
{{ $t("label.custom-groups") }}
</summary>
<ul>
<li v-for="group in customGroups" :key="group.name">
<router-link :to="{ name: 'group-name', params: { name: group.name } }" active-class="active-primary">
<ph:stack-simple />
<div class="truncate">
{{ group.name }}
</div>
</router-link>
</li>
</ul>
</details>
</li>
</ul>
</template>
<script lang="ts" setup>
const store = useSwarmStore();
const { stacks, services, customGroups } = storeToRefs(store);
const serivcesWithoutStacks = computed(() => services.value.filter((service) => !service.stack));
</script>
<style scoped lang="postcss">
.menu {
@apply text-[0.95rem];
}
</style>
@@ -0,0 +1,42 @@
<template>
<transition :name="!slideRight ? 'slide-left' : 'slide-right'" mode="out-in">
<div v-if="!slideRight">
<slot name="left" />
</div>
<div v-else>
<slot name="right" />
</div>
</transition>
</template>
<script lang="ts" setup>
const { slideRight } = defineProps<{ slideRight: boolean }>();
</script>
<style scoped lang="postcss">
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.1s ease-out;
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(20px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" :size="size">
<div class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" :size="size">
<slot></slot>
</div>
</template>
+6 -2
View File
@@ -1,7 +1,7 @@
<template>
<button class="btn relative overflow-hidden" @click="cancel()">
<div class="absolute inset-0 origin-left bg-white/30" ref="progress"></div>
<div class="z-10">
<div>
<slot></slot>
</div>
</button>
@@ -13,9 +13,13 @@ const finished = defineEmit();
const cancelled = defineEmit();
let animation: Animation | undefined;
const { duration = 4000 } = defineProps<{
duration?: number;
}>();
onMounted(async () => {
animation = progress.value?.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], {
duration: 4000,
duration: duration,
easing: "linear",
fill: "forwards",
});
+2 -2
View File
@@ -1,12 +1,12 @@
<template>
<labeled-input>
<LabeledInput>
<template #label>
<slot />
</template>
<template #input>
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" />
</template>
</labeled-input>
</LabeledInput>
</template>
<script lang="ts" setup>
+4 -3
View File
@@ -1,6 +1,7 @@
import { Container } from "@/models/Container";
type ContainerActions = "start" | "stop" | "restart";
export const useContainerActions = () => {
const { container } = useContainerContext();
export const useContainerActions = (container: Ref<Container>) => {
const { showToast } = useToast();
const actionStates = reactive({
@@ -10,7 +11,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",
-23
View File
@@ -1,23 +0,0 @@
import { Container } from "@/models/Container";
type ContainerContext = {
container: Ref<Container>;
streamConfig: { stdout: boolean; stderr: boolean };
};
export const containerContext = Symbol("containerContext") as InjectionKey<ContainerContext>;
export const provideContainerContext = (container: Ref<Container>) => {
provide(containerContext, {
container,
streamConfig: reactive({ stdout: true, stderr: true }),
});
};
export const useContainerContext = () => {
const context = inject(containerContext);
if (!context) {
throw new Error("No container context provided");
}
return context;
};
+222
View File
@@ -0,0 +1,222 @@
import { type Ref } from "vue";
import { encodeXML } from "entities";
import debounce from "lodash.debounce";
import {
type LogEvent,
type JSONObject,
LogEntry,
asLogEntry,
DockerEventLogEntry,
SkippedLogsEntry,
} from "@/models/LogEntry";
import { Service, Stack } from "@/models/Stack";
import { Container, GroupedContainers } from "@/models/Container";
function parseMessage(data: string): LogEntry<string | JSONObject> {
const e = JSON.parse(data, (key, value) => {
if (typeof value === "string") {
return encodeXML(value);
}
return value;
}) as LogEvent;
return asLogEntry(e);
}
export function useContainerStream(container: Ref<Container>): LogStreamSource {
const { streamConfig } = useLoggingContext();
const url = computed(() => {
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream?${new URLSearchParams(params).toString()}`,
);
});
const loadMoreUrl = computed(() => {
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs?${new URLSearchParams(params).toString()}`,
);
});
return useLogStream(url, loadMoreUrl);
}
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
const { streamConfig } = useLoggingContext();
const url = computed(() => {
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(`/api/stacks/${stack.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
});
return useLogStream(url);
}
export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource {
const { streamConfig } = useLoggingContext();
const url = computed(() => {
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(`/api/groups/${group.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
});
return useLogStream(url);
}
export function useMergedStream(containers: Ref<Container[]>): LogStreamSource {
const { streamConfig } = useLoggingContext();
const url = computed(() => {
const params = [
...Object.entries(streamConfig).map(([key, value]) => [key, value ? "1" : "0"]),
...containers.value.map((c) => ["id", c.id]),
];
return withBase(
`/api/hosts/${containers.value[0].host}/logs/mergedStream?${new URLSearchParams(params).toString()}`,
);
});
return useLogStream(url);
}
export function useServiceStream(service: Ref<Service>): LogStreamSource {
const { streamConfig } = useLoggingContext();
const url = computed(() => {
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(`/api/services/${service.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
});
return useLogStream(url);
}
export type LogStreamSource = ReturnType<typeof useLogStream>;
function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
let messages: LogEntry<string | JSONObject>[] = $ref([]);
let buffer: LogEntry<string | JSONObject>[] = $ref([]);
const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>);
function flushNow() {
if (messages.length > config.maxLogs) {
if (scrollingPaused) {
console.log("Skipping ", buffer.length, " log items");
if (messages.at(-1) instanceof SkippedLogsEntry) {
const lastEvent = messages.at(-1) as SkippedLogsEntry;
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
lastEvent.addSkippedEntries(buffer.length, lastItem);
} else {
const firstItem = buffer.at(0) as LogEntry<string | JSONObject>;
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem));
}
buffer = [];
} else {
messages.push(...buffer);
buffer = [];
messages = messages.slice(-config.maxLogs);
}
} else {
if (messages.length == 0) {
// sort the buffer the very first time because of multiple logs in parallel
buffer.sort((a, b) => a.date.getTime() - b.date.getTime());
}
messages.push(...buffer);
buffer = [];
}
}
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
let es: EventSource | null = null;
function close() {
if (es) {
es.close();
es = null;
}
}
function clearMessages() {
flushBuffer.cancel();
messages = [];
buffer = [];
}
function connect({ clear } = { clear: true }) {
close();
if (clear) {
clearMessages();
}
es = new EventSource(url.value);
es.addEventListener("container-stopped", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string };
buffer.push(new DockerEventLogEntry("Container stopped", event.actorId, new Date(), "container-stopped"));
flushBuffer();
flushBuffer.flush();
});
es.onmessage = (e) => {
if (e.data) {
buffer.push(parseMessage(e.data));
flushBuffer();
}
};
es.onerror = () => clearMessages();
}
watch(url, () => connect(), { immediate: true });
async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
if (!loadMoreUrl) return;
beforeLoading();
const to = messages[0].date;
const last = messages[messages.length - 1].date;
const delta = to.getTime() - last.getTime();
const from = new Date(to.getTime() + delta);
const logs = await (
await fetch(
`${loadMoreUrl.value}&${new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }).toString()}`,
)
).text();
if (logs) {
const newMessages = logs
.trim()
.split("\n")
.map((line) => parseMessage(line));
messages.unshift(...newMessages);
}
afterLoading();
}
// TODO this is a hack to connect the event source when the container is started
// watch(
// () => container.value.state,
// (newValue, oldValue) => {
// console.log("LogEventSource: container changed", newValue, oldValue);
// if (newValue == "running" && newValue != oldValue) {
// buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started"));
// connect({ clear: false });
// }
// },
// );
onScopeDispose(() => close());
return { ...$$({ messages }), loadOlderLogs };
}
-156
View File
@@ -1,156 +0,0 @@
import { type Ref } from "vue";
import { encodeXML } from "entities";
import debounce from "lodash.debounce";
import {
type LogEvent,
type JSONObject,
LogEntry,
asLogEntry,
DockerEventLogEntry,
SkippedLogsEntry,
} from "@/models/LogEntry";
function parseMessage(data: string): LogEntry<string | JSONObject> {
const e = JSON.parse(data, (key, value) => {
if (typeof value === "string") {
return encodeXML(value);
}
return value;
}) as LogEvent;
return asLogEntry(e);
}
export function useLogStream() {
const { container, streamConfig } = useContainerContext();
let messages: LogEntry<string | JSONObject>[] = $ref([]);
let buffer: LogEntry<string | JSONObject>[] = $ref([]);
const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>);
let containerId = container.value.id;
function flushNow() {
if (messages.length > config.maxLogs) {
if (scrollingPaused) {
console.log("Skipping ", buffer.length, " log items");
if (messages.at(-1) instanceof SkippedLogsEntry) {
const lastEvent = messages.at(-1) as SkippedLogsEntry;
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
lastEvent.addSkippedEntries(buffer.length, lastItem);
} else {
const firstItem = buffer.at(0) as LogEntry<string | JSONObject>;
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem));
}
buffer = [];
} else {
messages.push(...buffer);
buffer = [];
messages = messages.slice(-config.maxLogs);
}
} else {
messages.push(...buffer);
buffer = [];
}
}
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
let es: EventSource | null = null;
function close() {
if (es) {
es.close();
console.debug(`EventSource closed for ${containerId}`);
es = null;
}
}
function clearMessages() {
flushBuffer.cancel();
messages = [];
buffer = [];
console.debug(`Clearing messages for ${containerId}`);
}
function connect({ clear } = { clear: true }) {
close();
if (clear) {
clearMessages();
}
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
containerId = container.value.id;
console.debug(`Connecting to ${containerId} with params`, params);
es = new EventSource(
withBase(`/api/logs/stream/${container.value.host}/${containerId}?${new URLSearchParams(params).toString()}`),
);
es.addEventListener("container-stopped", () => {
close();
buffer.push(new DockerEventLogEntry("Container stopped", new Date(), "container-stopped"));
flushBuffer();
flushBuffer.flush();
});
es.onmessage = (e) => {
if (e.data) {
buffer.push(parseMessage(e.data));
flushBuffer();
}
};
es.onerror = () => clearMessages();
}
async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
if (messages.length < 300) return;
beforeLoading();
const to = messages[0].date;
const last = messages[299].date;
const delta = to.getTime() - last.getTime();
const from = new Date(to.getTime() + delta);
const params = Object.entries(streamConfig)
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), { from: from.toISOString(), to: to.toISOString() });
const logs = await (
await fetch(
withBase(`/api/logs/${container.value.host}/${containerId}?${new URLSearchParams(params).toString()}`),
)
).text();
if (logs) {
const newMessages = logs
.trim()
.split("\n")
.map((line) => parseMessage(line));
messages.unshift(...newMessages);
}
afterLoading();
}
watch(
() => container.value.state,
(newValue, oldValue) => {
console.log("LogEventSource: container changed", newValue, oldValue);
if (newValue == "running" && newValue != oldValue) {
buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started"));
connect({ clear: false });
}
},
);
onUnmounted(() => close());
watch(
() => container.value.id,
() => connect(),
{ immediate: true },
);
watch(streamConfig, () => connect());
return { ...$$({ messages }), loadOlderLogs };
}
+23
View File
@@ -0,0 +1,23 @@
import { Container } from "@/models/Container";
type LogContext = {
streamConfig: { stdout: boolean; stderr: boolean };
containers: Ref<Container[]>;
};
export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>;
export const provideLoggingContext = (containers: Ref<Container[]>) => {
provide(loggingContextKey, {
streamConfig: reactive({ stdout: true, stderr: true }),
containers,
});
};
export const useLoggingContext = () => {
const context = inject(loggingContextKey);
if (!context) {
throw new Error("No logging context provided");
}
return context;
};
+1 -1
View File
@@ -7,7 +7,7 @@ if (config.hosts.length === 1 && !sessionHost.value) {
sessionHost.value = config.hosts[0].id;
}
export function persistentVisibleKeys(container: Ref<Container>): Ref<string[][]> {
export function persistentVisibleKeysForContainer(container: Ref<Container>): Ref<string[][]> {
const storage = useProfileStorage("visibleKeys", {});
return computed(() => {
if (!(container.value.storageKey in storage.value)) {
+6 -6
View File
@@ -11,14 +11,14 @@
<router-view></router-view>
</pane>
<template v-if="!isMobile">
<pane v-for="other in activeContainers" :key="other.id">
<log-container
<pane v-for="other in pinnedLogs" :key="other.id">
<ContainerLog
:id="other.id"
show-title
scrollable
closable
@close="containerStore.removeActiveContainer(other)"
></log-container>
@close="pinnedLogsStore.unPinContainer(other)"
/>
</pane>
</template>
</splitpanes>
@@ -72,8 +72,8 @@
import { Splitpanes, Pane } from "splitpanes";
import { collapseNav } from "@/stores/settings";
const containerStore = useContainerStore();
const { activeContainers } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const { toasts, removeToast } = useToast();
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { Container } from "./Container";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
}));
describe("Container", () => {
const names = [
[
"foo.gb1cto7gaq68fp4refnsr5hep.byqr1prci82zyfoos6gx1yhz0",
"foo",
".gb1cto7gaq68fp4refnsr5hep.byqr1prci82zyfoos6gx1yhz0",
],
["bar.gb1cto7gaq68fp4refnsr5hep", "bar", ".gb1cto7gaq68fp4refnsr5hep"],
["baz", "baz", null],
];
test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => {
const c = new Container("id", new Date(), "image", name!, "command", "host", {}, "status", "created", []);
expect(c.name).toBe(expectedName);
expect(c.swarmId).toBe(expectedSwarmId);
});
});
+37 -14
View File
@@ -2,9 +2,7 @@ import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Con
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
import { Ref } from "vue";
type Stat = Omit<ContainerStat, "id">;
const SWARM_ID_REGEX = /(\.[a-z0-9]{25})+$/i;
export type Stat = Omit<ContainerStat, "id">;
const hosts = computed(() =>
config.hosts.reduce(
@@ -16,36 +14,39 @@ const hosts = computed(() =>
),
);
export class GroupedContainers {
constructor(
public readonly name: string,
public readonly containers: Container[],
) {}
}
export class Container {
private _stat: Ref<Stat>;
private readonly _statsHistory: Ref<Stat[]>;
public readonly swarmId: string | null = null;
public readonly isSwarm: boolean = false;
private readonly movingAverageStat: Ref<Stat>;
private readonly _name: string;
constructor(
public readonly id: string,
public readonly created: Date,
public readonly image: string,
public readonly name: string,
name: string,
public readonly command: string,
public readonly host: string,
public readonly labels = {} as Record<string, string>,
public status: string,
public state: ContainerState,
stats: Stat[],
public readonly group?: string,
public health?: ContainerHealth,
) {
this._stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
this._statsHistory = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat));
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
this._statsHistory = history;
this.movingAverageStat = useExponentialMovingAverage(this._stat, 0.2);
const match = name.match(SWARM_ID_REGEX);
if (match) {
this.swarmId = match[0];
this.name = name.replace(`${this.swarmId}`, "");
this.isSwarm = true;
}
this._name = name;
}
get statsHistory() {
@@ -68,6 +69,28 @@ export class Container {
return `${stripVersion(this.image)}:${this.command}`;
}
get namespace() {
return this.labels["com.docker.stack.namespace"] || this.labels["com.docker.compose.project"];
}
get customGroup() {
return this.group;
}
get name() {
return this.isSwarm
? this.labels["com.docker.swarm.task.name"].replace(`.${this.labels["com.docker.swarm.task.id"]}`, "")
: this._name;
}
get swarmId() {
return this.labels["com.docker.swarm.service.id"];
}
get isSwarm() {
return Boolean(this.labels["com.docker.swarm.service.id"]);
}
public updateStat(stat: Stat) {
if (isRef(this._stat)) {
this._stat.value = stat;
+20 -5
View File
@@ -17,12 +17,14 @@ export interface LogEvent {
readonly l: Level;
readonly p: Position;
readonly s: "stdout" | "stderr" | "unknown";
readonly c: string;
}
export abstract class LogEntry<T extends string | JSONObject> {
protected readonly _message: T;
constructor(
message: T,
public readonly containerID: string,
public readonly id: number,
public readonly date: Date,
public readonly std: Std,
@@ -41,13 +43,14 @@ export abstract class LogEntry<T extends string | JSONObject> {
export class SimpleLogEntry extends LogEntry<string> {
constructor(
message: string,
containerID: string,
id: number,
date: Date,
public readonly level: Level,
public readonly position: Position,
public readonly std: Std,
) {
super(message, id, date, std, level);
super(message, containerID, id, date, std, level);
}
getComponent(): Component {
return SimpleLogItem;
@@ -59,13 +62,14 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
constructor(
message: JSONObject,
containerID: string,
id: number,
date: Date,
public readonly level: Level,
public readonly std: Std,
visibleKeys?: Ref<string[][]>,
) {
super(message, id, date, std, level);
super(message, containerID, id, date, std, level);
if (visibleKeys) {
this.filteredMessage = computed(() => {
if (!visibleKeys.value.length) {
@@ -91,17 +95,26 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
}
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry {
return new ComplexLogEntry(event._message, event.id, event.date, event.level, event.std, visibleKeys);
return new ComplexLogEntry(
event._message,
event.containerID,
event.id,
event.date,
event.level,
event.std,
visibleKeys,
);
}
}
export class DockerEventLogEntry extends LogEntry<string> {
constructor(
message: string,
containerID: string,
date: Date,
public readonly event: "container-stopped" | "container-started",
) {
super(message, date.getTime(), date, "stderr", "info");
super(message, containerID, date.getTime(), date, "stderr", "info");
}
getComponent(): Component {
return DockerEventLogItem;
@@ -118,7 +131,7 @@ export class SkippedLogsEntry extends LogEntry<string> {
public readonly firstSkipped: LogEntry<string | JSONObject>,
lastSkipped: LogEntry<string | JSONObject>,
) {
super("", date.getTime(), date, "stderr", "info");
super("", "", date.getTime(), date, "stderr", "info");
this._totalSkipped = totalSkipped;
this.lastSkipped = lastSkipped;
}
@@ -148,6 +161,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
if (isObject(event.m)) {
return new ComplexLogEntry(
event.m,
event.c,
event.id,
new Date(event.ts),
event.l,
@@ -156,6 +170,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
} else {
return new SimpleLogEntry(
event.m,
event.c,
event.id,
new Date(event.ts),
event.l,
+22
View File
@@ -0,0 +1,22 @@
import { Container } from "@/models/Container";
export class Stack {
constructor(
public readonly name: string,
public readonly containers: Container[],
public readonly services: Service[],
) {
for (const service of services) {
service.stack = this;
}
}
}
export class Service {
constructor(
public readonly name: string,
public readonly containers: Container[],
) {}
stack?: Stack;
}
+2 -2
View File
@@ -1,5 +1,5 @@
<template>
<page-with-links>
<PageWithLinks>
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
@@ -7,7 +7,7 @@
</div>
</div>
</div>
</page-with-links>
</PageWithLinks>
</template>
<script lang="ts" setup>
+8 -11
View File
@@ -1,11 +1,6 @@
<template>
<search></search>
<log-container
:id="id"
:show-title="true"
:scrollable="activeContainers.length > 0"
v-if="currentContainer"
></log-container>
<Search />
<ContainerLog :id="id" :show-title="true" :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<div v-else-if="ready" class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
@@ -16,12 +11,14 @@
</template>
<script lang="ts" setup>
import search from "@/components/Search.vue";
const store = useContainerStore();
const { id } = defineProps<{ id: string }>();
const currentContainer = store.currentContainer($$(id));
const { activeContainers, ready } = storeToRefs(store);
const containerStore = useContainerStore();
const currentContainer = containerStore.currentContainer($$(id));
const { ready } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
watchEffect(() => {
if (ready.value) {
+22
View File
@@ -0,0 +1,22 @@
<template>
<Search />
<GroupedLog :name="name" :scrollable="pinnedLogs.length > 0" />
</template>
<script lang="ts" setup>
const { name } = defineProps<{ name: string }>();
const swarmStore = useSwarmStore();
const { customGroups } = storeToRefs(swarmStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const group = computed(() => customGroups.value.find((g) => g.name === name));
watchEffect(() => {
if (group.value?.name) {
setTitle(group.value.name + " group");
}
});
</script>
+7 -44
View File
@@ -1,68 +1,31 @@
<template>
<page-with-links class="gap-16">
<PageWithLinks>
<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.toFixed(0) }}%</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>
<HostList />
</section>
<section>
<container-table :containers="runningContainers"></container-table>
<ContainerTable :containers="runningContainers"></ContainerTable>
</section>
</page-with-links>
</PageWithLinks>
</template>
<script lang="ts" setup>
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>
+2 -3
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"
@@ -50,8 +50,7 @@ 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",
});
+22
View File
@@ -0,0 +1,22 @@
<template>
<Search />
<MultiContainerLog :ids="ids" :scrollable="pinnedLogs.length > 0" />
</template>
<script lang="ts" setup>
const containerStore = useContainerStore();
const { ready } = storeToRefs(containerStore);
const route = useRoute();
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const ids = toRef(() => route.query.id as string[]);
watchEffect(() => {
if (ready.value) {
setTitle("Merged Logs");
}
});
</script>
+28
View File
@@ -0,0 +1,28 @@
<template>
<Search />
<ServiceLog :name="name" :scrollable="pinnedLogs.length > 0" />
</template>
<script lang="ts" setup>
const { name } = defineProps<{ name: string }>();
const containerStore = useContainerStore();
const { ready } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const stackStore = useSwarmStore();
const { services } = storeToRefs(stackStore);
const service = computed(() => services.value.find((s) => s.name === name));
watchEffect(() => {
if (ready.value) {
if (service.value?.name) {
setTitle(service.value.name);
} else {
setTitle("Not Found");
}
}
});
</script>
+24 -21
View File
@@ -1,5 +1,5 @@
<template>
<page-with-links>
<PageWithLinks>
<section>
<div class="has-underline">
<h2>{{ $t("settings.about") }}</h2>
@@ -21,17 +21,17 @@
<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>
<Toggle v-model="compact"> {{ $t("settings.compact") }} </Toggle>
<toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </toggle>
<Toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </Toggle>
<toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</toggle>
<Toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</Toggle>
<toggle v-model="showStd">{{ $t("settings.show-std") }}</toggle>
<Toggle v-model="showStd">{{ $t("settings.show-std") }}</Toggle>
<toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</toggle>
<Toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</Toggle>
<labeled-input>
<LabeledInput>
<template #label>
{{ $t("settings.locale") }}
</template>
@@ -44,9 +44,9 @@
]"
/>
</template>
</labeled-input>
</LabeledInput>
<labeled-input>
<LabeledInput>
<template #label>
{{ $t("settings.datetime-format") }}
</template>
@@ -72,9 +72,9 @@
/>
</div>
</template>
</labeled-input>
</LabeledInput>
<labeled-input>
<LabeledInput>
<template #label>
{{ $t("settings.font-size") }}
</template>
@@ -88,9 +88,9 @@
]"
/>
</template>
</labeled-input>
</LabeledInput>
<labeled-input>
<LabeledInput>
<template #label>
{{ $t("settings.color-scheme") }}
</template>
@@ -104,12 +104,13 @@
]"
/>
</template>
</labeled-input>
</LabeledInput>
</div>
<log-viewer
<LogList
:messages="fakeMessages"
:visible-keys="keys"
:last-selected-item="undefined"
:show-container-name="false"
class="hidden overflow-hidden rounded-lg border border-base-content/50 shadow @3xl:block"
/>
</section>
@@ -133,7 +134,7 @@
<toggle v-model="automaticRedirect">{{ $t("settings.automatic-redirect") }}</toggle>
</div>
</section>
</page-with-links>
</PageWithLinks>
</template>
<script lang="ts" setup>
@@ -168,11 +169,11 @@ const hoursAgo = (hours: number) => {
};
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 SimpleLogEntry("This is a preview of the logs", "123", 1, hoursAgo(16), "info", undefined, "stdout"),
new SimpleLogEntry("A warning log looks like this", "123", 2, hoursAgo(12), "warn", undefined, "stdout"),
new SimpleLogEntry("This is a multi line error message", "123", 3, hoursAgo(7), "error", "start", "stderr"),
new SimpleLogEntry("with a second line", "123", 4, hoursAgo(2), "error", "middle", "stderr"),
new SimpleLogEntry("and finally third line.", "123", 5, new Date(), "error", "end", "stderr"),
new ComplexLogEntry(
{
message: "This is a complex log entry as json",
@@ -181,6 +182,7 @@ const fakeMessages = [
key2: "value2",
},
},
"123",
6,
new Date(),
"info",
@@ -189,6 +191,7 @@ const fakeMessages = [
),
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. ",
"123",
7,
new Date(),
"debug",
+28
View File
@@ -0,0 +1,28 @@
<template>
<Search />
<StackLog :name="name" :scrollable="pinnedLogs.length > 0" />
</template>
<script lang="ts" setup>
const { name } = defineProps<{ name: string }>();
const containerStore = useContainerStore();
const { ready } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const stackStore = useSwarmStore();
const { stacks } = storeToRefs(stackStore);
const stack = computed(() => stacks.value.find((s) => s.name === name));
watchEffect(() => {
if (ready.value) {
if (stack.value?.name) {
setTitle(stack.value.name);
} else {
setTitle("Not Found");
}
}
});
</script>
+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,
};
+22 -12
View File
@@ -4,13 +4,14 @@ import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Cont
import { Container } from "@/models/Container";
import i18n from "@/modules/i18n";
const { showToast } = useToast();
const { showToast, removeToast } = useToast();
const { markHostAvailable } = useHosts();
// @ts-ignore
const { t } = i18n.global;
export const useContainerStore = defineStore("container", () => {
const containers: Ref<Container[]> = ref([]);
const activeContainerIds: Ref<string[]> = ref([]);
let es: EventSource | null = null;
const ready = ref(false);
@@ -29,14 +30,12 @@ export const useContainerStore = defineStore("container", () => {
return containers.value.filter(filter);
});
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
function connect() {
es?.close();
ready.value = false;
es = new EventSource(withBase("/api/events/stream"));
es.addEventListener("error", (e) => {
if (es?.readyState === EventSource.CLOSED) {
if (es?.readyState === EventSource.CONNECTING) {
showToast(
{
id: "events-stream",
@@ -68,6 +67,11 @@ export const useContainerStore = defineStore("container", () => {
}
});
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];
@@ -77,6 +81,7 @@ export const useContainerStore = defineStore("container", () => {
});
es.onopen = () => {
removeToast("events-stream");
if (containers.value.length > 0) {
containers.value = [];
}
@@ -128,6 +133,7 @@ export const useContainerStore = defineStore("container", () => {
c.status,
c.state,
c.stats,
c.group,
c.health,
);
}),
@@ -135,19 +141,23 @@ export const useContainerStore = defineStore("container", () => {
};
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
const appendActiveContainer = ({ id }: { id: string }) => activeContainerIds.value.push(id);
const removeActiveContainer = ({ id }: { id: string }) =>
activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1);
const containerNames = computed(() =>
containers.value.reduce(
(acc, container) => {
acc[container.id] = container.name;
return acc;
},
{} as Record<string, string>,
),
);
return {
containers,
activeContainerIds,
allContainersById,
visibleContainers,
activeContainers,
currentContainer,
appendActiveContainer,
removeActiveContainer,
containerNames,
ready,
};
});
+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,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { Ref } from "vue";
export const usePinnedLogsStore = defineStore("pinnedLogs", () => {
const containerStore = useContainerStore();
const { allContainersById } = storeToRefs(containerStore);
const pinnedContainerIds: Ref<string[]> = ref([]);
const pinnedLogs = computed(() => pinnedContainerIds.value.map((id) => allContainersById.value[id]));
const pinContainer = ({ id }: { id: string }) => pinnedContainerIds.value.push(id);
const unPinContainer = ({ id }: { id: string }) =>
pinnedContainerIds.value.splice(pinnedContainerIds.value.indexOf(id), 1);
const isPinned = ({ id }: { id: string }) => pinnedContainerIds.value.includes(id);
return {
pinnedLogs,
isPinned,
pinContainer,
unPinContainer,
};
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(usePinnedLogsStore, import.meta.hot));
}
+87
View File
@@ -0,0 +1,87 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { Container, GroupedContainers } from "@/models/Container";
import { Service, Stack } from "@/models/Stack";
export const useSwarmStore = defineStore("swarm", () => {
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
const stacks = computed(() => {
const namespaced: Record<string, Container[]> = {};
for (const item of runningContainers.value) {
const namespace = item.namespace;
if (namespace === undefined) continue;
namespaced[namespace] ||= [];
namespaced[namespace].push(item);
}
const newStacks: Stack[] = [];
for (const [name, containers] of Object.entries(namespaced)) {
const services: Record<string, Container[]> = {};
for (const container of containers) {
const service = container.labels["com.docker.swarm.service.name"];
if (service === undefined) continue;
services[service] ||= [];
services[service].push(container);
}
const newServices: Service[] = [];
for (const [name, containers] of Object.entries(services)) {
newServices.push(new Service(name, containers));
}
newStacks.push(new Stack(name, containers, newServices));
}
return newStacks;
});
const services = computed(() => {
const services: Record<string, Container[]> = {};
for (const container of runningContainers.value) {
const service = container.labels["com.docker.swarm.service.name"];
const namespace = container.namespace;
if (service === undefined) continue;
if (namespace) continue; // skip containers that already have a stack
services[service] ||= [];
services[service].push(container);
}
const serviceWithStack = stacks.value.flatMap((stack) => stack.services);
const servicesWithoutStack = Object.entries(services).map(([name, containers]) => new Service(name, containers));
return [...serviceWithStack, ...servicesWithoutStack];
});
const customGroups = computed(() => {
const grouped: Record<string, Container[]> = {};
for (const container of runningContainers.value) {
const group = container.customGroup;
if (group === undefined) continue;
grouped[group] ||= [];
grouped[group].push(container);
}
return Object.entries(grouped).map(([name, containers]) => new GroupedContainers(name, containers));
});
return {
stacks,
services,
customGroups,
};
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useSwarmStore, import.meta.hot));
}
+1
View File
@@ -17,6 +17,7 @@ export type ContainerJson = {
readonly labels: Record<string, string>;
readonly stats: ContainerStat[];
readonly health?: ContainerHealth;
readonly group?: string;
};
export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting";
+14 -1
View File
@@ -73,5 +73,18 @@ export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHist
{ deep },
);
return history;
const reset = ({ initial = [] }: Pick<UseSimpleRefHistoryOptions<T>, "initial">) => {
history.value = initial;
};
return { history, reset };
}
export function hashCode(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
+9 -3
View File
@@ -1,4 +1,3 @@
version: "3.4"
services:
custom_base:
container_name: custom_base
@@ -44,7 +43,8 @@ services:
build:
context: .
depends_on:
- proxy
proxy:
condition: service_healthy
proxy:
container_name: proxy
@@ -53,12 +53,18 @@ services:
- /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.42.1-jammy
image: mcr.microsoft.com/playwright:v1.44.1-jammy
working_dir: /app
volumes:
- .:/app
+11 -19
View File
@@ -64,11 +64,13 @@ export default defineConfig({
text: "Advanced Configuration",
items: [
{ text: "Authentication", link: "/guide/authentication" },
{ text: "Swarm Mode", link: "/guide/swarm-mode" },
{ text: "Changing Base", link: "/guide/changing-base" },
{ text: "Healthcheck", link: "/guide/healthcheck" },
{ 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" },
@@ -81,6 +83,13 @@ export default defineConfig({
{ text: "Debugging", link: "/guide/debugging" },
],
},
{
text: "About",
items: [
{ text: "Team", link: "/team" },
{ text: "Support", link: "/support" },
],
},
],
footer: {
@@ -99,24 +108,7 @@ export default defineConfig({
],
},
transformHtml: (_, id, { pageData }) => {
if (!/[\\/]404\.html$/.test(id))
links.push({
// you might need to change this if not using clean urls mode
url: pageData.relativePath.replace(/((^|\/)index)?\.md$/, "$2"),
lastmod: pageData.lastUpdated,
});
},
buildEnd: async ({ outDir }) => {
const sitemap = new SitemapStream({
hostname: "https://dozzle.dev/",
});
const writeStream = createWriteStream(resolve(outDir, "sitemap.xml"));
sitemap.pipe(writeStream);
links.forEach((link) => sitemap.write(link));
const promise = streamToPromise(sitemap);
sitemap.end();
await promise;
sitemap: {
hostname: "https://dozzle.dev/",
},
});
@@ -0,0 +1,10 @@
<script setup></script>
<template>
<div class="content">
<a href="https://www.buymeacoffee.com/amirraminfar"
><img
src="https://img.buymeacoffee.com/button-api/?text=Buy me a beer&emoji=🍺&slug=amirraminfar&button_colour=FFDD00&font_colour=000000&font_family=Poppins&outline_colour=000000&coffee_colour=ffffff"
/></a>
</div>
</template>
+2
View File
@@ -4,12 +4,14 @@ import Theme from "vitepress/theme";
import "uno.css";
import "./style.css";
import HeroVideo from "./components/HeroVideo.vue";
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
export default {
...Theme,
Layout: () => {
return h(Theme.Layout, null, {
"home-hero-image": () => h(HeroVideo),
"sidebar-nav-after": () => h(BuyMeCoffee),
});
},
enhanceApp({ app, router, siteData }) {},
+2 -1
View File
@@ -1,13 +1,14 @@
/* 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']
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+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` command to generate `users.yml`. Here is an example:
```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`.
+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.
+7 -6
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.
@@ -39,10 +41,10 @@ services:
If you are in a private network then you can use [Docker Socket Proxy](https://github.com/Tecnativa/docker-socket-proxy) which expose `docker.sock` file without the need of TLS. Dozzle will never try to write to Docker but it will need access to list APIs. The following command will start a proxy with minimal access.
```sh
docker container run --privileged -e CONTAINERS=1 -v /var/run/docker.sock:/var/run/docker.sock -p 2375:2375 tecnativa/docker-socket-proxy
docker container run --privileged -e CONTAINERS=1 -e INFO=1 -v /var/run/docker.sock:/var/run/docker.sock -p 2375:2375 tecnativa/docker-socket-proxy
```
Note that `CONTAINERS=1` is required to list running containers. `EVENTS` is also needed but it is enabled by default.
> [!INFO] > `CONTAINERS=1` is required to list running containers. `EVENTS` is also needed but it is enabled by default. `INFO=1` is optional but it will provide more information on host meta data.
Running Dozzle without any certs should work. Here is an example:
@@ -50,9 +52,8 @@ Running Dozzle without any certs should work. Here is an example:
docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --remote-host tcp://123.1.1.1:2375
```
::: warning
Exposing `docker.sock` publicly is not safe. Only use a proxy for an internal network where all clients are trusted.
:::
> [!WARNING]
> Docker Socket Proxy is not recommended for production use. It is only for private networks.
## Adding labels to hosts
+43
View File
@@ -0,0 +1,43 @@
---
title: Swarm Mode
---
# Introducing Swarm Mode
Dozzle added "Swarm Mode" in version 7 which supports Docker [stacks](https://docs.docker.com/reference/cli/docker/stack/deploy/), [services](https://docs.docker.com/engine/swarm/how-swarm-mode-works/services/) and custom groups for joining logs together. Dozzle does not use Swarm API internally as it is limited. Dozzle implements its own grouping using swarm labels. Additionally, Dozzle merges stats for containers in a group. This means that you can see logs and stats for all containers in a group in one view. But it does mean that each host needs to be setup with Dozzle.
Dozzle swarm mode is automatically enabled when services or customer groups are found. If you are not using services, you can still take advantage of Dozzle's grouping feature by adding a label to your containers.
## Custom Groups
Custom groups are created by adding a label to your container. The label is `dev.dozzle.group` and the value is the name of the group. All containers with the same group name will be joined together in the UI. For example, if you have a group named `myapp`, all containers with the label `dozzle.group=myapp` will be joined together.
Here is an example using Docker Compose or Docker CLI:
::: code-group
```sh
docker run --label dev.dozzle.group=myapp hello-world
```
```yaml [docker-compose.yml]
version: "3"
services:
dozzle:
image: hello-world
labels:
- dev.dozzle.group=myapp
```
:::
## Merging Logs and Stats
Dozzle merges logs and stats for containers in a group. This means that you can see logs and stats for all containers in a group in one view. This is useful for applications that have multiple containers that work together. Dozzle will automatically find new containers in a group and add them to the view as they are started.
> [!INFO]
> Automatic discovery of new containers is only available for services and custom groups. If you using merging logs in host mode, only specific containers will be shown. You can still use custom groups to merge logs for containers in swarm mode.
## Service Discovery
Dozzle uses Docker API to discover services and custom groups. This means that Dozzle will automatically find new containers in a group and add them to the view as they are started. This is useful for applications that have multiple containers that work together. Labels that are used are `com.docker.stack.namespace` and `com.docker.compose.project` for grouping containers. For services, Dozzle uses the service name as the group name which is `com.docker.swarm.service.name`.
+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 -5
View File
@@ -3,11 +3,10 @@
"type": "module",
"name": "docs",
"devDependencies": {
"@unocss/preset-typography": "^0.58.7",
"@unocss/reset": "^0.58.7",
"@unocss/transformer-directives": "^0.58.7",
"@unocss/preset-typography": "^0.60.3",
"@unocss/reset": "^0.60.3",
"@unocss/transformer-directives": "^0.60.3",
"dozzle": "workspace:*",
"sitemap": "^7.1.1",
"unocss": "^0.58.7"
"unocss": "^0.60.3"
}
}
+1303 -1128
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
---
title: Support
---
# Support
Dozzle is an open-source project and is maintained by its creator and contributors. If you have any questions, issues, or feature requests, please feel free to reach out to us.
There are many ways to support us:
- We are always looking for contributors. If you are interested in contributing to the project, please check out the [Issues](https://github.com/amir20/dozzle/issues).
- If you like the project, please consider giving it a star on [GitHub](https://github.com/amir20/dozzle).
- If you would like to support the project financially, you can [Buy Me a Coffee](https://www.buymeacoffee.com/amirraminfar).
- Blog about the project, tweet about it, or share it on social media.
Thank you for your support! 🙏
+40
View File
@@ -0,0 +1,40 @@
---
layout: page
title: Team
---
<script setup>
import {
VPTeamPage,
VPTeamPageTitle,
VPTeamMembers
} from 'vitepress/theme'
const members = [
{
avatar: 'https://www.github.com/amir20.png',
name: 'Amir Raminfar',
title: 'Creator',
links: [
{ icon: 'github', link: 'https://github.com/amir20' },
{ icon: 'linkedin', link: 'https://www.linkedin.com/in/amirraminfar' },
{ icon: {svg: '<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z"/></svg>'}, link: 'https://www.buymeacoffee.com/amirraminfar' }
]
}
]
</script>
<VPTeamPage>
<VPTeamPageTitle>
<template #title>
Team
</template>
<template #lead>
Dozzle is created and maintained by the following people.
</template>
</VPTeamPageTitle>
<VPTeamMembers
:members="members"
/>
</VPTeamPage>
+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("label.running-containers")).toHaveText("Contenedores en ejecución");
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("label.running-containers")).toHaveText("Running Containers");
await expect(page.getByTestId("settings")).toBeVisible();
});

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