From ba0206a903cd91ad856462c41d836185dd65c668 Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Thu, 23 May 2024 10:17:16 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20supports=20swarm=20mode=20with=20stacks?= =?UTF-8?q?=20and=20services=20on=20remote=20hosts=20=F0=9F=99=8C?= =?UTF-8?q?=F0=9F=8F=BC=20(#2961)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/auto-imports.d.ts | 58 +++- assets/components.d.ts | 32 +- assets/components/BarChart.vue | 20 -- .../{LogViewer => }/ContainerPopup.vue | 2 +- .../ContainerActionsToolbar.vue} | 15 +- .../ContainerHealth.vue | 0 .../ContainerViewer/ContainerLog.vue | 54 ++++ .../ContainerTitle.vue | 16 +- assets/components/FuzzySearchModal.vue | 9 +- .../components/GroupedViewer/GroupedLog.vue | 46 +++ assets/components/HostMenu.vue | 33 +-- assets/components/InfiniteLoader.vue | 1 + assets/components/Links.vue | 6 +- .../components/LogViewer/ComplexLogItem.vue | 16 +- .../LogViewer/ContainerLogViewer.vue | 39 --- assets/components/LogViewer/ContainerName.vue | 45 +++ assets/components/LogViewer/ContainerStat.vue | 36 --- .../LogViewer/DockerEventLogItem.vue | 59 ++-- ...ventSource.spec.ts => EventSource.spec.ts} | 25 +- assets/components/LogViewer/EventSource.vue | 28 ++ assets/components/LogViewer/FieldList.vue | 7 +- assets/components/LogViewer/LogContainer.vue | 53 ---- assets/components/LogViewer/LogDate.vue | 6 +- .../components/LogViewer/LogEventSource.vue | 19 -- assets/components/LogViewer/LogList.vue | 73 +++++ assets/components/LogViewer/LogStd.vue | 4 +- assets/components/LogViewer/LogViewer.vue | 88 ++---- .../LogViewer/LogViewerWithSource.vue | 18 -- .../LogViewer/MultiContainerStat.vue | 78 +++++ assets/components/LogViewer/SimpleLogItem.vue | 12 +- .../LogViewer/SkippedEntriesLogItem.vue | 8 +- assets/components/LogViewer/StatMonitor.vue | 2 +- .../components/LogViewer/ViewerWithSource.vue | 38 +++ ....spec.ts.snap => EventSource.spec.ts.snap} | 62 ++-- .../MultiContainerLog.vue | 43 +++ assets/components/PageWithLinks.vue | 2 +- assets/components/Releases.vue | 4 +- assets/components/ScrollProgress.vue | 8 +- assets/components/ScrollableView.vue | 4 +- .../components/ServiceViewer/ServiceLog.vue | 45 +++ assets/components/SideMenu.vue | 233 ++------------- assets/components/StackViewer/StackLog.vue | 47 +++ assets/components/SwarmMenu.vue | 82 +++++ assets/components/common/SlideTransition.vue | 42 +++ assets/components/common/Tag.vue | 2 +- assets/components/common/Toggle.vue | 4 +- assets/composable/containerActions.ts | 5 +- assets/composable/containerContext.ts | 2 - assets/composable/eventStreams.ts | 222 ++++++++++++++ assets/composable/eventsource.ts | 160 ---------- assets/composable/logContext.ts | 23 ++ assets/composable/storage.ts | 2 +- assets/layouts/default.vue | 12 +- assets/models/Container.spec.ts | 15 +- assets/models/Container.ts | 18 +- assets/models/LogEntry.ts | 25 +- assets/models/Stack.ts | 22 ++ assets/pages/[...all].vue | 4 +- assets/pages/container/[id].vue | 19 +- assets/pages/group/[name].vue | 22 ++ assets/pages/index.vue | 8 +- assets/pages/merged/index.vue | 22 ++ assets/pages/service/[name].vue | 28 ++ assets/pages/settings.vue | 45 +-- assets/pages/stack/[name].vue | 28 ++ assets/stores/container.ts | 23 +- assets/stores/pinned.ts | 27 ++ assets/stores/swarm.ts | 87 ++++++ assets/types/Container.d.ts | 1 + assets/utils/index.ts | 9 + docker-compose.yml | 1 - .../dark-homepage-1-Mobile-Chrome-linux.png | Bin 10593 -> 9708 bytes .../dark-homepage-1-chromium-linux.png | Bin 14428 -> 13812 bytes ...default-homepage-1-Mobile-Chrome-linux.png | Bin 10879 -> 9956 bytes .../default-homepage-1-chromium-linux.png | Bin 14095 -> 13557 bytes internal/docker/client.go | 9 +- internal/docker/client_test.go | 2 +- internal/docker/container_store.go | 44 ++- internal/docker/event_generator.go | 30 +- internal/docker/event_generator_test.go | 8 +- internal/docker/types.go | 14 +- internal/web/__snapshots__/web.snapshot | 98 +++--- internal/web/events.go | 1 - internal/web/logs.go | 280 +++++++++++++++--- internal/web/logs_test.go | 68 ++++- internal/web/routes.go | 6 +- locales/en.yml | 5 +- package.json | 1 + pnpm-lock.yaml | 11 +- 89 files changed, 1931 insertions(+), 1000 deletions(-) delete mode 100644 assets/components/BarChart.vue rename assets/components/{LogViewer => }/ContainerPopup.vue (87%) rename assets/components/{LogViewer/LogActionsToolbar.vue => ContainerViewer/ContainerActionsToolbar.vue} (90%) rename assets/components/{LogViewer => ContainerViewer}/ContainerHealth.vue (100%) create mode 100644 assets/components/ContainerViewer/ContainerLog.vue rename assets/components/{LogViewer => ContainerViewer}/ContainerTitle.vue (69%) create mode 100644 assets/components/GroupedViewer/GroupedLog.vue delete mode 100644 assets/components/LogViewer/ContainerLogViewer.vue create mode 100644 assets/components/LogViewer/ContainerName.vue delete mode 100644 assets/components/LogViewer/ContainerStat.vue rename assets/components/LogViewer/{LogEventSource.spec.ts => EventSource.spec.ts} (87%) create mode 100644 assets/components/LogViewer/EventSource.vue delete mode 100644 assets/components/LogViewer/LogContainer.vue delete mode 100644 assets/components/LogViewer/LogEventSource.vue create mode 100644 assets/components/LogViewer/LogList.vue delete mode 100644 assets/components/LogViewer/LogViewerWithSource.vue create mode 100644 assets/components/LogViewer/MultiContainerStat.vue create mode 100644 assets/components/LogViewer/ViewerWithSource.vue rename assets/components/LogViewer/__snapshots__/{LogEventSource.spec.ts.snap => EventSource.spec.ts.snap} (69%) create mode 100644 assets/components/MultiContainerViewer/MultiContainerLog.vue create mode 100644 assets/components/ServiceViewer/ServiceLog.vue create mode 100644 assets/components/StackViewer/StackLog.vue create mode 100644 assets/components/SwarmMenu.vue create mode 100644 assets/components/common/SlideTransition.vue create mode 100644 assets/composable/eventStreams.ts delete mode 100644 assets/composable/eventsource.ts create mode 100644 assets/composable/logContext.ts create mode 100644 assets/models/Stack.ts create mode 100644 assets/pages/group/[name].vue create mode 100644 assets/pages/merged/index.vue create mode 100644 assets/pages/service/[name].vue create mode 100644 assets/pages/stack/[name].vue create mode 100644 assets/stores/pinned.ts create mode 100644 assets/stores/swarm.ts diff --git a/assets/auto-imports.d.ts b/assets/auto-imports.d.ts index ebae61d0..0be5c422 100644 --- a/assets/auto-imports.d.ts +++ b/assets/auto-imports.d.ts @@ -58,6 +58,8 @@ declare global { const getDeep: typeof import('./utils/index')['getDeep'] const globalShowPopup: typeof import('./composable/popup')['globalShowPopup'] const h: typeof import('vue')['h'] + const hash: typeof import('./utils/index')['hash'] + 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 +73,10 @@ declare global { const isRef: typeof import('vue')['isRef'] const lightTheme: typeof import('./stores/settings')['lightTheme'] const locale: typeof import('./stores/settings')['locale'] + const logContext: typeof import('./composable/logContext')['logContext'] + const logContextKey: typeof import('./composable/logContext')['logContextKey'] 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'] @@ -102,10 +107,14 @@ declare global { 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 provideServiceContext: typeof import('./composable/serviceContext')['provideServiceContext'] + const provideStackContext: typeof import('./composable/stackContext')['provideStackContext'] const reactify: typeof import('@vueuse/core')['reactify'] const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] const reactive: typeof import('vue')['reactive'] @@ -123,6 +132,7 @@ declare global { const resolveRef: typeof import('@vueuse/core')['resolveRef'] const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] const search: typeof import('./stores/settings')['search'] + const serviceContext: typeof import('./composable/serviceContext')['serviceContext'] const sessionHost: typeof import('./composable/storage')['sessionHost'] const setActivePinia: typeof import('pinia')['setActivePinia'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] @@ -137,6 +147,7 @@ declare global { const size: typeof import('./stores/settings')['size'] const smallerScrollbars: typeof import('./stores/settings')['smallerScrollbars'] const softWrap: typeof import('./stores/settings')['softWrap'] + const stackContext: typeof import('./composable/stackContext')['stackContext'] const storeToRefs: typeof import('pinia')['storeToRefs'] const stripVersion: typeof import('./utils/index')['stripVersion'] const syncRef: typeof import('@vueuse/core')['syncRef'] @@ -189,7 +200,9 @@ declare global { const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] const useContainerActions: typeof import('./composable/containerActions')['useContainerActions'] const useContainerContext: typeof import('./composable/containerContext')['useContainerContext'] + const useContainerContextLogStream: typeof import('./composable/eventStreams')['useContainerContextLogStream'] 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,6 +242,7 @@ 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'] @@ -243,13 +257,15 @@ 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 useLogStream: typeof import('./composable/stackEventStream')['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'] @@ -266,6 +282,10 @@ declare global { const useParentElement: typeof import('@vueuse/core')['useParentElement'] const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver'] const usePermission: typeof import('@vueuse/core')['usePermission'] + const usePinned: typeof import('./stores/pinned')['usePinned'] + const usePinnedContainers: typeof import('./stores/pinned')['usePinnedContainers'] + const usePinnedLogsStore: typeof import('./stores/pinned')['usePinnedLogsStore'] + const usePinnedStore: typeof import('./stores/pinned')['usePinnedStore'] const usePointer: typeof import('@vueuse/core')['usePointer'] const usePointerLock: typeof import('@vueuse/core')['usePointerLock'] const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe'] @@ -289,6 +309,9 @@ declare global { const useScrollLock: typeof import('@vueuse/core')['useScrollLock'] const useSearchFilter: typeof import('./composable/search')['useSearchFilter'] const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta'] + const useServiceContext: typeof import('./composable/serviceContext')['useServiceContext'] + const useServiceContextLogStream: typeof import('./composable/eventStreams')['useServiceContextLogStream'] + 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'] @@ -296,11 +319,16 @@ declare global { const useSorted: typeof import('@vueuse/core')['useSorted'] const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] + const useStackContext: typeof import('./composable/stackContext')['useStackContext'] + const useStackContextLogStream: typeof import('./composable/eventStreams')['useStackContextLogStream'] + const useStackStore: typeof import('./stores/swarm')['useStackStore'] + 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'] @@ -417,6 +445,7 @@ declare module 'vue' { readonly getDeep: UnwrapRef readonly globalShowPopup: UnwrapRef readonly h: UnwrapRef + readonly hashCode: UnwrapRef readonly hourStyle: UnwrapRef readonly ignorableWatch: UnwrapRef readonly inject: UnwrapRef @@ -430,6 +459,7 @@ declare module 'vue' { readonly isRef: UnwrapRef readonly lightTheme: UnwrapRef readonly locale: UnwrapRef + readonly loggingContextKey: UnwrapRef readonly makeDestructurable: UnwrapRef readonly mapActions: UnwrapRef readonly mapGetters: UnwrapRef @@ -459,11 +489,12 @@ declare module 'vue' { readonly onUnmounted: UnwrapRef readonly onUpdated: UnwrapRef readonly pausableWatch: UnwrapRef - readonly persistentVisibleKeys: UnwrapRef + readonly persistentVisibleKeysForContainer: UnwrapRef readonly pinnedContainers: UnwrapRef readonly provide: UnwrapRef readonly provideContainerContext: UnwrapRef readonly provideLocal: UnwrapRef + readonly provideLoggingContext: UnwrapRef readonly reactify: UnwrapRef readonly reactifyObject: UnwrapRef readonly reactive: UnwrapRef @@ -548,6 +579,7 @@ declare module 'vue' { readonly useContainerActions: UnwrapRef readonly useContainerContext: UnwrapRef readonly useContainerStore: UnwrapRef + readonly useContainerStream: UnwrapRef readonly useCounter: UnwrapRef readonly useCssModule: UnwrapRef readonly useCssVar: UnwrapRef @@ -587,6 +619,7 @@ declare module 'vue' { readonly useFullscreen: UnwrapRef readonly useGamepad: UnwrapRef readonly useGeolocation: UnwrapRef + readonly useGroupedStream: UnwrapRef readonly useHead: UnwrapRef readonly useHosts: UnwrapRef readonly useI18n: UnwrapRef @@ -601,13 +634,14 @@ declare module 'vue' { readonly useLink: UnwrapRef readonly useLocalStorage: UnwrapRef readonly useLogSearchContext: UnwrapRef - readonly useLogStream: UnwrapRef + readonly useLoggingContext: UnwrapRef readonly useMagicKeys: UnwrapRef readonly useManualRefHistory: UnwrapRef readonly useMediaControls: UnwrapRef readonly useMediaQuery: UnwrapRef readonly useMemoize: UnwrapRef readonly useMemory: UnwrapRef + readonly useMergedStream: UnwrapRef readonly useMounted: UnwrapRef readonly useMouse: UnwrapRef readonly useMouseInElement: UnwrapRef @@ -624,6 +658,7 @@ declare module 'vue' { readonly useParentElement: UnwrapRef readonly usePerformanceObserver: UnwrapRef readonly usePermission: UnwrapRef + readonly usePinnedLogsStore: UnwrapRef readonly usePointer: UnwrapRef readonly usePointerLock: UnwrapRef readonly usePointerSwipe: UnwrapRef @@ -647,6 +682,7 @@ declare module 'vue' { readonly useScrollLock: UnwrapRef readonly useSearchFilter: UnwrapRef readonly useSeoMeta: UnwrapRef + readonly useServiceStream: UnwrapRef readonly useSessionStorage: UnwrapRef readonly useShare: UnwrapRef readonly useSimpleRefHistory: UnwrapRef @@ -654,11 +690,13 @@ declare module 'vue' { readonly useSorted: UnwrapRef readonly useSpeechRecognition: UnwrapRef readonly useSpeechSynthesis: UnwrapRef + readonly useStackStream: UnwrapRef readonly useStepper: UnwrapRef readonly useStorage: UnwrapRef readonly useStorageAsync: UnwrapRef readonly useStyleTag: UnwrapRef readonly useSupported: UnwrapRef + readonly useSwarmStore: UnwrapRef readonly useSwipe: UnwrapRef readonly useTemplateRefsList: UnwrapRef readonly useTextDirection: UnwrapRef @@ -768,6 +806,7 @@ declare module '@vue/runtime-core' { readonly getDeep: UnwrapRef readonly globalShowPopup: UnwrapRef readonly h: UnwrapRef + readonly hashCode: UnwrapRef readonly hourStyle: UnwrapRef readonly ignorableWatch: UnwrapRef readonly inject: UnwrapRef @@ -781,6 +820,7 @@ declare module '@vue/runtime-core' { readonly isRef: UnwrapRef readonly lightTheme: UnwrapRef readonly locale: UnwrapRef + readonly loggingContextKey: UnwrapRef readonly makeDestructurable: UnwrapRef readonly mapActions: UnwrapRef readonly mapGetters: UnwrapRef @@ -810,11 +850,12 @@ declare module '@vue/runtime-core' { readonly onUnmounted: UnwrapRef readonly onUpdated: UnwrapRef readonly pausableWatch: UnwrapRef - readonly persistentVisibleKeys: UnwrapRef + readonly persistentVisibleKeysForContainer: UnwrapRef readonly pinnedContainers: UnwrapRef readonly provide: UnwrapRef readonly provideContainerContext: UnwrapRef readonly provideLocal: UnwrapRef + readonly provideLoggingContext: UnwrapRef readonly reactify: UnwrapRef readonly reactifyObject: UnwrapRef readonly reactive: UnwrapRef @@ -899,6 +940,7 @@ declare module '@vue/runtime-core' { readonly useContainerActions: UnwrapRef readonly useContainerContext: UnwrapRef readonly useContainerStore: UnwrapRef + readonly useContainerStream: UnwrapRef readonly useCounter: UnwrapRef readonly useCssModule: UnwrapRef readonly useCssVar: UnwrapRef @@ -938,6 +980,7 @@ declare module '@vue/runtime-core' { readonly useFullscreen: UnwrapRef readonly useGamepad: UnwrapRef readonly useGeolocation: UnwrapRef + readonly useGroupedStream: UnwrapRef readonly useHead: UnwrapRef readonly useHosts: UnwrapRef readonly useI18n: UnwrapRef @@ -952,13 +995,14 @@ declare module '@vue/runtime-core' { readonly useLink: UnwrapRef readonly useLocalStorage: UnwrapRef readonly useLogSearchContext: UnwrapRef - readonly useLogStream: UnwrapRef + readonly useLoggingContext: UnwrapRef readonly useMagicKeys: UnwrapRef readonly useManualRefHistory: UnwrapRef readonly useMediaControls: UnwrapRef readonly useMediaQuery: UnwrapRef readonly useMemoize: UnwrapRef readonly useMemory: UnwrapRef + readonly useMergedStream: UnwrapRef readonly useMounted: UnwrapRef readonly useMouse: UnwrapRef readonly useMouseInElement: UnwrapRef @@ -975,6 +1019,7 @@ declare module '@vue/runtime-core' { readonly useParentElement: UnwrapRef readonly usePerformanceObserver: UnwrapRef readonly usePermission: UnwrapRef + readonly usePinnedLogsStore: UnwrapRef readonly usePointer: UnwrapRef readonly usePointerLock: UnwrapRef readonly usePointerSwipe: UnwrapRef @@ -998,6 +1043,7 @@ declare module '@vue/runtime-core' { readonly useScrollLock: UnwrapRef readonly useSearchFilter: UnwrapRef readonly useSeoMeta: UnwrapRef + readonly useServiceStream: UnwrapRef readonly useSessionStorage: UnwrapRef readonly useShare: UnwrapRef readonly useSimpleRefHistory: UnwrapRef @@ -1005,11 +1051,13 @@ declare module '@vue/runtime-core' { readonly useSorted: UnwrapRef readonly useSpeechRecognition: UnwrapRef readonly useSpeechSynthesis: UnwrapRef + readonly useStackStream: UnwrapRef readonly useStepper: UnwrapRef readonly useStorage: UnwrapRef readonly useStorageAsync: UnwrapRef readonly useStyleTag: UnwrapRef readonly useSupported: UnwrapRef + readonly useSwarmStore: UnwrapRef readonly useSwipe: UnwrapRef readonly useTemplateRefsList: UnwrapRef readonly useTextDirection: UnwrapRef diff --git a/assets/components.d.ts b/assets/components.d.ts index 8302228e..70c620fb 100644 --- a/assets/components.d.ts +++ b/assets/components.d.ts @@ -7,7 +7,6 @@ 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,34 +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'] @@ -67,15 +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'] @@ -83,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'] } } diff --git a/assets/components/BarChart.vue b/assets/components/BarChart.vue deleted file mode 100644 index 36ecd024..00000000 --- a/assets/components/BarChart.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/assets/components/LogViewer/ContainerPopup.vue b/assets/components/ContainerPopup.vue similarity index 87% rename from assets/components/LogViewer/ContainerPopup.vue rename to assets/components/ContainerPopup.vue index 83443b08..c65d0705 100644 --- a/assets/components/LogViewer/ContainerPopup.vue +++ b/assets/components/ContainerPopup.vue @@ -2,7 +2,7 @@
RUNNING - +
diff --git a/assets/components/LogViewer/LogActionsToolbar.vue b/assets/components/ContainerViewer/ContainerActionsToolbar.vue similarity index 90% rename from assets/components/LogViewer/LogActionsToolbar.vue rename to assets/components/ContainerViewer/ContainerActionsToolbar.vue index c789ea34..a54368af 100644 --- a/assets/components/LogViewer/LogActionsToolbar.vue +++ b/assets/components/ContainerViewer/ContainerActionsToolbar.vue @@ -8,7 +8,7 @@
  • {{ $t("toolbar.clear") }} - +
  • @@ -17,7 +17,7 @@
  • {{ $t("toolbar.search") }} - +
  • @@ -101,15 +101,18 @@ diff --git a/assets/components/LogViewer/ContainerTitle.vue b/assets/components/ContainerViewer/ContainerTitle.vue similarity index 69% rename from assets/components/LogViewer/ContainerTitle.vue rename to assets/components/ContainerViewer/ContainerTitle.vue index cfdbc5e7..953b36d1 100644 --- a/assets/components/LogViewer/ContainerTitle.vue +++ b/assets/components/ContainerViewer/ContainerTitle.vue @@ -17,22 +17,24 @@ {{ container.swarmId }}
    - - diff --git a/assets/components/HostMenu.vue b/assets/components/HostMenu.vue index 8a38931e..97605338 100644 --- a/assets/components/HostMenu.vue +++ b/assets/components/HostMenu.vue @@ -30,11 +30,12 @@ {{ label.startsWith("label.") ? $t(label) : label }} - all +
      @@ -43,7 +44,7 @@
      @@ -52,8 +53,8 @@ @@ -83,9 +84,11 @@ import Stack from "~icons/ph/stack"; // @ts-ignore import Containers from "~icons/octicon/container-24"; -const store = useContainerStore(); +const containerStore = useContainerStore(); +const { visibleContainers } = storeToRefs(containerStore); + +const pinnedStore = usePinnedLogsStore(); -const { activeContainers, visibleContainers } = storeToRefs(store); const { hosts } = useHosts(); const setHost = (host: string | null) => (sessionHost.value = host); @@ -121,7 +124,7 @@ const menuItems = computed(() => { const singular = []; for (const item of sortedContainers.value) { - const namespace = item.labels["com.docker.stack.namespace"] ?? item.labels["com.docker.compose.project"]; + const namespace = item.group; if (debouncedPinnedContainers.value.has(item.name)) { pinned.push(item); } else if (namespace) { @@ -156,16 +159,6 @@ const menuItems = computed(() => { return items; }); - -const activeContainersById = computed(() => - activeContainers.value.reduce( - (acc, item) => { - acc[item.id] = item; - return acc; - }, - {} as Record, - ), -); diff --git a/assets/components/LogViewer/ContainerName.vue b/assets/components/LogViewer/ContainerName.vue new file mode 100644 index 00000000..77ee7d0a --- /dev/null +++ b/assets/components/LogViewer/ContainerName.vue @@ -0,0 +1,45 @@ + + + + diff --git a/assets/components/LogViewer/ContainerStat.vue b/assets/components/LogViewer/ContainerStat.vue deleted file mode 100644 index 6fc76699..00000000 --- a/assets/components/LogViewer/ContainerStat.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/assets/components/LogViewer/DockerEventLogItem.vue b/assets/components/LogViewer/DockerEventLogItem.vue index 30dcc41b..37f70b8d 100644 --- a/assets/components/LogViewer/DockerEventLogItem.vue +++ b/assets/components/LogViewer/DockerEventLogItem.vue @@ -1,25 +1,33 @@ @@ -31,20 +39,19 @@ const { t } = useI18n(); const { logEntry } = defineProps<{ logEntry: DockerEventLogEntry; + showContainerName?: boolean; }>(); -const store = useContainerStore(); -const { containers } = storeToRefs(store); -const { container } = useContainerContext(); +const { containers } = useLoggingContext(); const nextContainer = computed( () => [ ...containers.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", ), ].sort((a, b) => +a.created - +b.created)[0], diff --git a/assets/components/LogViewer/LogEventSource.spec.ts b/assets/components/LogViewer/EventSource.spec.ts similarity index 87% rename from assets/components/LogViewer/LogEventSource.spec.ts rename to assets/components/LogViewer/EventSource.spec.ts index 36235b98..09448f69 100644 --- a/assets/components/LogViewer/LogEventSource.spec.ts +++ b/assets/components/LogViewer/EventSource.spec.ts @@ -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("", () => { +describe("", () => { const search = useSearchFilter(); beforeEach(() => { @@ -67,26 +67,29 @@ describe("", () => { ], }); - return mount(LogEventSource, { + return mount(Component, { 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: ` - + `, }, - props: {}, + props: { + streamSource: useContainerStream, + entity: new Container("abc", new Date(), "image", "name", "command", "localhost", {}, "status", "created", []), + }, }); } diff --git a/assets/components/LogViewer/EventSource.vue b/assets/components/LogViewer/EventSource.vue new file mode 100644 index 00000000..ac870a61 --- /dev/null +++ b/assets/components/LogViewer/EventSource.vue @@ -0,0 +1,28 @@ + + + diff --git a/assets/components/LogViewer/FieldList.vue b/assets/components/LogViewer/FieldList.vue index aa6aca83..0ead59f6 100644 --- a/assets/components/LogViewer/FieldList.vue +++ b/assets/components/LogViewer/FieldList.vue @@ -3,12 +3,7 @@
    • diff --git a/assets/stores/container.ts b/assets/stores/container.ts index cc46d6f1..cf140fff 100644 --- a/assets/stores/container.ts +++ b/assets/stores/container.ts @@ -11,7 +11,7 @@ const { t } = i18n.global; export const useContainerStore = defineStore("container", () => { const containers: Ref = ref([]); - const activeContainerIds: Ref = ref([]); + let es: EventSource | null = null; const ready = ref(false); @@ -30,8 +30,6 @@ 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; @@ -135,6 +133,7 @@ export const useContainerStore = defineStore("container", () => { c.status, c.state, c.stats, + c.group, c.health, ); }), @@ -142,19 +141,23 @@ export const useContainerStore = defineStore("container", () => { }; const currentContainer = (id: Ref) => 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, + ), + ); return { containers, - activeContainerIds, allContainersById, visibleContainers, - activeContainers, currentContainer, - appendActiveContainer, - removeActiveContainer, + containerNames, ready, }; }); diff --git a/assets/stores/pinned.ts b/assets/stores/pinned.ts new file mode 100644 index 00000000..f473b05b --- /dev/null +++ b/assets/stores/pinned.ts @@ -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 = 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)); +} diff --git a/assets/stores/swarm.ts b/assets/stores/swarm.ts new file mode 100644 index 00000000..38b0bebe --- /dev/null +++ b/assets/stores/swarm.ts @@ -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 }; + + const runningContainers = computed(() => containers.value.filter((c) => c.state === "running")); + + const stacks = computed(() => { + const namespaced: Record = {}; + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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)); +} diff --git a/assets/types/Container.d.ts b/assets/types/Container.d.ts index 61103a6c..b1011cc5 100644 --- a/assets/types/Container.d.ts +++ b/assets/types/Container.d.ts @@ -17,6 +17,7 @@ export type ContainerJson = { readonly labels: Record; readonly stats: ContainerStat[]; readonly health?: ContainerHealth; + readonly group?: string; }; export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting"; diff --git a/assets/utils/index.ts b/assets/utils/index.ts index 7a2f8fae..2dddcbd1 100644 --- a/assets/utils/index.ts +++ b/assets/utils/index.ts @@ -75,3 +75,12 @@ export function useSimpleRefHistory(source: Ref, options: UseSimpleRefHist return history; } + +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; +} diff --git a/docker-compose.yml b/docker-compose.yml index 5ded3c95..837b8220 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.4" services: custom_base: container_name: custom_base diff --git a/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png b/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png index 610ee47ff4199bcb4ef32a18dd5f1795da9bd95b..79a9e5b9d6c7e57c17573b4b6b0b83032081d04b 100644 GIT binary patch literal 9708 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 MH`6P>^XU2i0M=I~qyPW_ literal 10593 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 literal 14428 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 Hm%siCN!OL_ literal 10879 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 diff --git a/internal/docker/client.go b/internal/docker/client.go index 167630bc..87c50c6f 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -213,6 +213,12 @@ func (d *httpClient) ListContainers() ([]Container, error) { if len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } + + group := "" + if c.Labels["dev.dozzle.group"] != "" { + group = c.Labels["dev.dozzle.group"] + } + container := Container{ ID: c.ID[:12], Names: c.Names, @@ -227,6 +233,7 @@ func (d *httpClient) ListContainers() ([]Container, error) { Health: findBetweenParentheses(c.Status), Labels: c.Labels, Stats: utils.NewRingBuffer[ContainerStat](300), // 300 seconds of stats + Group: group, } containers = append(containers, container) } @@ -303,7 +310,7 @@ func (d *httpClient) ContainerLogs(ctx context.Context, id string, since string, ShowStdout: stdType&STDOUT != 0, ShowStderr: stdType&STDERR != 0, Follow: true, - Tail: "300", + Tail: strconv.Itoa(100), Timestamps: true, Since: since, } diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index dbf1e1ba..4ea288cc 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -149,7 +149,7 @@ func Test_dockerClient_ContainerLogs_happy(t *testing.T) { b = append(b, []byte(expected)...) reader := io.NopCloser(bytes.NewReader(b)) - options := container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "300", Timestamps: true, Since: "since"} + options := container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "100", Timestamps: true, Since: "since"} proxy.On("ContainerLogs", mock.Anything, id, options).Return(reader, nil) client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}} diff --git a/internal/docker/container_store.go b/internal/docker/container_store.go index 245d1f84..72156132 100644 --- a/internal/docker/container_store.go +++ b/internal/docker/container_store.go @@ -11,25 +11,27 @@ import ( ) type ContainerStore struct { - containers *xsync.MapOf[string, *Container] - subscribers *xsync.MapOf[context.Context, chan ContainerEvent] - client Client - statsCollector *StatsCollector - wg sync.WaitGroup - connected atomic.Bool - events chan ContainerEvent - ctx context.Context + containers *xsync.MapOf[string, *Container] + subscribers *xsync.MapOf[context.Context, chan ContainerEvent] + newContainerSubscribers *xsync.MapOf[context.Context, chan Container] + client Client + statsCollector *StatsCollector + wg sync.WaitGroup + connected atomic.Bool + events chan ContainerEvent + ctx context.Context } func NewContainerStore(ctx context.Context, client Client) *ContainerStore { s := &ContainerStore{ - containers: xsync.NewMapOf[string, *Container](), - client: client, - subscribers: xsync.NewMapOf[context.Context, chan ContainerEvent](), - statsCollector: NewStatsCollector(client), - wg: sync.WaitGroup{}, - events: make(chan ContainerEvent), - ctx: ctx, + containers: xsync.NewMapOf[string, *Container](), + client: client, + subscribers: xsync.NewMapOf[context.Context, chan ContainerEvent](), + newContainerSubscribers: xsync.NewMapOf[context.Context, chan Container](), + statsCollector: NewStatsCollector(client), + wg: sync.WaitGroup{}, + events: make(chan ContainerEvent), + ctx: ctx, } s.wg.Add(1) @@ -105,6 +107,10 @@ func (s *ContainerStore) SubscribeStats(ctx context.Context, stats chan Containe s.statsCollector.Subscribe(ctx, stats) } +func (s *ContainerStore) SubscribeNewContainers(ctx context.Context, containers chan Container) { + s.newContainerSubscribers.Store(ctx, containers) +} + func (s *ContainerStore) init() { stats := make(chan ContainerStat) s.statsCollector.Subscribe(s.ctx, stats) @@ -122,6 +128,14 @@ func (s *ContainerStore) init() { if container, err := s.client.FindContainer(event.ActorID); err == nil { log.Debugf("container %s started", container.ID) s.containers.Store(container.ID, &container) + s.newContainerSubscribers.Range(func(c context.Context, containers chan Container) bool { + select { + case containers <- container: + case <-c.Done(): + s.newContainerSubscribers.Delete(c) + } + return true + }) } case "destroy": log.Debugf("container %s destroyed", event.ActorID) diff --git a/internal/docker/event_generator.go b/internal/docker/event_generator.go index 6728004a..eb780c9d 100644 --- a/internal/docker/event_generator.go +++ b/internal/docker/event_generator.go @@ -22,13 +22,14 @@ import ( ) type EventGenerator struct { - Events chan *LogEvent - Errors chan error - reader *bufio.Reader - next *LogEvent - buffer chan *LogEvent - tty bool - wg sync.WaitGroup + Events chan *LogEvent + Errors chan error + reader *bufio.Reader + next *LogEvent + buffer chan *LogEvent + tty bool + wg sync.WaitGroup + containerID string } var bufPool = sync.Pool{ @@ -39,13 +40,14 @@ var bufPool = sync.Pool{ var ErrBadHeader = fmt.Errorf("dozzle/docker: unable to read header") -func NewEventGenerator(reader io.Reader, tty bool) *EventGenerator { +func NewEventGenerator(reader io.Reader, container Container) *EventGenerator { generator := &EventGenerator{ - reader: bufio.NewReader(reader), - buffer: make(chan *LogEvent, 100), - Errors: make(chan error, 1), - Events: make(chan *LogEvent), - tty: tty, + reader: bufio.NewReader(reader), + buffer: make(chan *LogEvent, 100), + Errors: make(chan error, 1), + Events: make(chan *LogEvent), + tty: container.Tty, + containerID: container.ID, } generator.wg.Add(2) go generator.consumeReader() @@ -84,7 +86,7 @@ func (g *EventGenerator) consumeReader() { message, streamType, readerError := readEvent(g.reader, g.tty) if message != "" { logEvent := createEvent(message, streamType) - + logEvent.ContainerID = g.containerID logEvent.Level = guessLogLevel(logEvent) g.buffer <- logEvent } diff --git a/internal/docker/event_generator_test.go b/internal/docker/event_generator_test.go index 2fc001a9..7c4f4e62 100644 --- a/internal/docker/event_generator_test.go +++ b/internal/docker/event_generator_test.go @@ -19,7 +19,7 @@ func TestEventGenerator_Events_tty(t *testing.T) { input := "example input" reader := bufio.NewReader(strings.NewReader(input)) - g := NewEventGenerator(reader, true) + g := NewEventGenerator(reader, Container{Tty: true}) event := <-g.Events require.NotNil(t, event, "Expected event to not be nil, but got nil") @@ -30,7 +30,7 @@ func TestEventGenerator_Events_non_tty(t *testing.T) { input := "example input" reader := bytes.NewReader(makeMessage(input, STDOUT)) - g := NewEventGenerator(reader, false) + g := NewEventGenerator(reader, Container{Tty: false}) event := <-g.Events require.NotNil(t, event, "Expected event to not be nil, but got nil") @@ -41,7 +41,7 @@ func TestEventGenerator_Events_non_tty_close_channel(t *testing.T) { input := "example input" reader := bytes.NewReader(makeMessage(input, STDOUT)) - g := NewEventGenerator(reader, false) + g := NewEventGenerator(reader, Container{Tty: false}) <-g.Events _, ok := <-g.Events @@ -52,7 +52,7 @@ func TestEventGenerator_Events_routines_done(t *testing.T) { input := "example input" reader := bytes.NewReader(makeMessage(input, STDOUT)) - g := NewEventGenerator(reader, false) + g := NewEventGenerator(reader, Container{Tty: false}) <-g.Events assert.False(t, waitTimeout(&g.wg, 1*time.Second), "Expected routines to be done") } diff --git a/internal/docker/types.go b/internal/docker/types.go index f17250e4..e13b69df 100644 --- a/internal/docker/types.go +++ b/internal/docker/types.go @@ -22,6 +22,7 @@ type Container struct { Tty bool `json:"-"` Labels map[string]string `json:"labels,omitempty"` Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"` + Group string `json:"group,omitempty"` } // ContainerStat represent stats instant for a container @@ -48,12 +49,13 @@ const ( ) type LogEvent struct { - Message any `json:"m,omitempty"` - Timestamp int64 `json:"ts"` - Id uint32 `json:"id,omitempty"` - Level string `json:"l,omitempty"` - Position LogPosition `json:"p,omitempty"` - Stream string `json:"s,omitempty"` + Message any `json:"m,omitempty"` + Timestamp int64 `json:"ts"` + Id uint32 `json:"id,omitempty"` + Level string `json:"l,omitempty"` + Position LogPosition `json:"p,omitempty"` + Stream string `json:"s,omitempty"` + ContainerID string `json:"c,omitempty"` } func (l *LogEvent) HasLevel() bool { diff --git a/internal/web/__snapshots__/web.snapshot b/internal/web/__snapshots__/web.snapshot index d1a20b47..aac1bec9 100644 --- a/internal/web/__snapshots__/web.snapshot +++ b/internal/web/__snapshots__/web.snapshot @@ -24,12 +24,12 @@ Location: /foobar/ Moved Permanently. /* snapshot: Test_createRoutes_redirect_with_auth */ -HTTP/1.1 307 Temporary Redirect -Connection: close -Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; -Content-Type: text/html; charset=utf-8 -Location: /foobar/login - +HTTP/1.1 307 Temporary Redirect +Connection: close +Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; +Content-Type: text/html; charset=utf-8 +Location: /foobar/login + Temporary Redirect. /* snapshot: Test_createRoutes_simple_redirect */ @@ -42,33 +42,33 @@ Location: /login?redirectUrl=/ Temporary Redirect. /* snapshot: Test_createRoutes_username_password */ -HTTP/1.1 307 Temporary Redirect -Connection: close -Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; -Content-Type: text/html; charset=utf-8 -Location: /login - +HTTP/1.1 307 Temporary Redirect +Connection: close +Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; +Content-Type: text/html; charset=utf-8 +Location: /login + Temporary Redirect. /* snapshot: Test_createRoutes_username_password_invalid */ -HTTP/1.1 401 Unauthorized -Connection: close -Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; -Content-Type: text/plain; charset=utf-8 -X-Content-Type-Options: nosniff - +HTTP/1.1 401 Unauthorized +Connection: close +Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + Unauthorized /* snapshot: Test_createRoutes_username_password_valid_session */ -HTTP/1.1 200 OK -Connection: close -Cache-Control: no-transform -Cache-Control: no-cache -Connection: keep-alive -Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; -Content-Type: text/event-stream -X-Accel-Buffering: no - +HTTP/1.1 200 OK +Connection: close +Cache-Control: no-transform +Cache-Control: no-cache +Connection: keep-alive +Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; +Content-Type: text/event-stream +X-Accel-Buffering: no + event: container-stopped data: end of stream @@ -86,8 +86,8 @@ Connection: close Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Type: application/x-jsonl; charset=UTF-8 -{"m":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout"} -{"m":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr"} +{"m":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"} +{"m":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"} /* snapshot: Test_handler_download_logs */ INFO Testing logs... @@ -105,15 +105,15 @@ event: containers-changed data: [] /* snapshot: Test_handler_streamEvents_error_request */ -HTTP/1.1 200 OK -Connection: close -Cache-Control: no-transform -Cache-Control: no-cache -Connection: keep-alive -Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; -Content-Type: text/event-stream -X-Accel-Buffering: no - +HTTP/1.1 200 OK +Connection: close +Cache-Control: no-transform +Cache-Control: no-cache +Connection: keep-alive +Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; +Content-Type: text/event-stream +X-Accel-Buffering: no + event: containers-changed data: [] @@ -148,17 +148,14 @@ X-Content-Type-Options: nosniff error finding container /* snapshot: Test_handler_streamLogs_error_reading */ -HTTP/1.1 500 Internal Server Error +HTTP/1.1 200 OK Connection: close Cache-Control: no-transform Cache-Control: no-cache Connection: keep-alive Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; -Content-Type: text/plain; charset=utf-8 -X-Accel-Buffering: no -X-Content-Type-Options: nosniff - -test error +Content-Type: text/event-stream +X-Accel-Buffering: no /* snapshot: Test_handler_streamLogs_error_std */ HTTP/1.1 400 Bad Request @@ -179,10 +176,10 @@ Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; i Content-Type: text/event-stream X-Accel-Buffering: no -data: {"m":"INFO Testing logs...","ts":0,"id":4256192898,"l":"info","s":"stdout"} +data: {"m":"INFO Testing logs...","ts":0,"id":4256192898,"l":"info","s":"stdout","c":"123456"} event: container-stopped -data: end of stream +data: {"actorId":"123456","name":"container-stopped","host":"localhost"} /* snapshot: Test_handler_streamLogs_happy_container_stopped */ HTTP/1.1 200 OK @@ -192,10 +189,7 @@ Cache-Control: no-cache Connection: keep-alive Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Type: text/event-stream -X-Accel-Buffering: no - -event: container-stopped -data: end of stream +X-Accel-Buffering: no /* snapshot: Test_handler_streamLogs_happy_with_id */ HTTP/1.1 200 OK @@ -207,8 +201,8 @@ Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; i Content-Type: text/event-stream X-Accel-Buffering: no -data: {"m":"INFO Testing logs...","ts":1589396137772,"id":1469707724,"l":"info","s":"stdout"} +data: {"m":"INFO Testing logs...","ts":1589396137772,"id":1469707724,"l":"info","s":"stdout","c":"123456"} id: 1589396137772 event: container-stopped -data: end of stream \ No newline at end of file +data: {"actorId":"123456","name":"container-stopped","host":"localhost"} \ No newline at end of file diff --git a/internal/web/events.go b/internal/web/events.go index 146c77ce..c428b79f 100644 --- a/internal/web/events.go +++ b/internal/web/events.go @@ -43,7 +43,6 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { } store.SubscribeStats(ctx, stats) store.Subscribe(ctx, events) - } defer func() { diff --git a/internal/web/logs.go b/internal/web/logs.go index 18cb7f7b..e85315fb 100644 --- a/internal/web/logs.go +++ b/internal/web/logs.go @@ -107,7 +107,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) return } - g := docker.NewEventGenerator(reader, container.Tty) + g := docker.NewEventGenerator(reader, container) encoder := json.NewEncoder(w) for event := range g.Events { @@ -117,9 +117,206 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) } } -func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") +func (h *handler) newContainers(ctx context.Context) chan docker.Container { + containers := make(chan docker.Container) + for _, store := range h.stores { + store.SubscribeNewContainers(ctx, containers) + } + return containers +} + +func (h *handler) streamContainerLogs(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + container, err := h.clientFromRequest(r).FindContainer(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + containers := make(chan docker.Container, 1) + containers <- container + + go func() { + newContainers := h.newContainers(r.Context()) + for { + select { + case container := <-newContainers: + if container.ID == id { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamContainerLogs") + return + } + } + case <-r.Context().Done(): + log.Debugf("closing container channel streamContainerLogs") + return + } + } + }() + + streamLogsForContainers(w, r, h.clients, containers) +} + +func (h *handler) streamLogsMerged(w http.ResponseWriter, r *http.Request) { + if !r.URL.Query().Has("id") { + http.Error(w, "ids query parameter is required", http.StatusBadRequest) + return + } + + containers := make(chan docker.Container, len(r.URL.Query()["id"])) + + for _, id := range r.URL.Query()["id"] { + container, err := h.clientFromRequest(r).FindContainer(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + containers <- container + } + + streamLogsForContainers(w, r, h.clients, containers) +} + +func (h *handler) streamServiceLogs(w http.ResponseWriter, r *http.Request) { + service := chi.URLParam(r, "service") + containers := make(chan docker.Container, 10) + + go func() { + for _, store := range h.stores { + list, err := store.List() + if err != nil { + log.Errorf("error while listing containers %v", err.Error()) + return + } + + for _, container := range list { + if container.State == "running" && (container.Labels["com.docker.swarm.service.name"] == service) { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamServiceLogs") + return + } + } + } + + } + newContainers := h.newContainers(r.Context()) + for { + select { + case container := <-newContainers: + if container.State == "running" && (container.Labels["com.docker.swarm.service.name"] == service) { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamServiceLogs") + return + } + } + case <-r.Context().Done(): + log.Debugf("closing container channel streamServiceLogs") + return + } + } + }() + + streamLogsForContainers(w, r, h.clients, containers) +} + +func (h *handler) streamGroupedLogs(w http.ResponseWriter, r *http.Request) { + group := chi.URLParam(r, "group") + containers := make(chan docker.Container, 10) + + go func() { + for _, store := range h.stores { + list, err := store.List() + if err != nil { + log.Errorf("error while listing containers %v", err.Error()) + return + } + + for _, container := range list { + if container.State == "running" && (container.Group == group) { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamServiceLogs") + return + } + } + } + } + newContainers := h.newContainers(r.Context()) + for { + select { + case container := <-newContainers: + if container.State == "running" && (container.Group == group) { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamServiceLogs") + return + } + } + case <-r.Context().Done(): + log.Debugf("closing container channel streamServiceLogs") + return + } + } + }() + + streamLogsForContainers(w, r, h.clients, containers) +} + +func (h *handler) streamStackLogs(w http.ResponseWriter, r *http.Request) { + stack := chi.URLParam(r, "stack") + containers := make(chan docker.Container, 10) + + go func() { + for _, store := range h.stores { + list, err := store.List() + if err != nil { + log.Errorf("error while listing containers %v", err.Error()) + return + } + + for _, container := range list { + if container.State == "running" && (container.Labels["com.docker.stack.namespace"] == stack) { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamStackLogs") + return + } + } + } + } + newContainers := h.newContainers(r.Context()) + for { + select { + case container := <-newContainers: + if container.State == "running" && (container.Labels["com.docker.stack.namespace"] == stack) { + select { + case containers <- container: + case <-r.Context().Done(): + log.Debugf("closing container channel streamStackLogs") + return + } + } + case <-r.Context().Done(): + log.Debugf("closing container channel streamStackLogs") + return + } + } + }() + + streamLogsForContainers(w, r, h.clients, containers) +} + +func streamLogsForContainers(w http.ResponseWriter, r *http.Request, clients map[string]docker.Client, containers chan docker.Container) { var stdTypes docker.StdType if r.URL.Query().Has("stdout") { stdTypes |= docker.STDOUT @@ -139,47 +336,22 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) { return } - container, err := h.clientFromRequest(r).FindContainer(id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-transform") w.Header().Add("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") - lastEventId := r.Header.Get("Last-Event-ID") - if len(r.URL.Query().Get("lastEventId")) > 0 { - lastEventId = r.URL.Query().Get("lastEventId") - } - - reader, err := h.clientFromRequest(r).ContainerLogs(r.Context(), container.ID, lastEventId, stdTypes) - if err != nil { - if err == io.EOF { - fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") - f.Flush() - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } + logs := make(chan *docker.LogEvent) + events := make(chan *docker.ContainerEvent) ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() - g := docker.NewEventGenerator(reader, container.Tty) - loop: for { select { - case event, ok := <-g.Events: - if !ok { - log.WithFields(log.Fields{"id": id}).Debug("stream closed") - break loop - } + case event := <-logs: if buf, err := json.Marshal(event); err != nil { log.Errorf("json encoding error while streaming %v", err.Error()) } else { @@ -193,27 +365,49 @@ loop: case <-ticker.C: fmt.Fprintf(w, ":ping \n\n") f.Flush() - } - } + case container := <-containers: + go func(container docker.Container) { + reader, err := clients[container.Host].ContainerLogs(r.Context(), container.ID, "", stdTypes) + if err != nil { + return + } + g := docker.NewEventGenerator(reader, container) + for event := range g.Events { + logs <- event + } + select { + case err := <-g.Errors: + if err != nil { + if err == io.EOF { + log.WithError(err).Debugf("stream closed for container %v", container.Name) + events <- &docker.ContainerEvent{ActorID: container.ID, Name: "container-stopped", Host: container.Host} + } else if err != r.Context().Err() { + log.Errorf("unknown error while streaming %v", err.Error()) + } + } + default: + // do nothing + } + }(container) - select { - case err := <-g.Errors: - if err != nil { - if err == io.EOF { - log.Debugf("container stopped: %v", container.ID) - fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") + case event := <-events: + log.Debugf("received container event %v", event) + if buf, err := json.Marshal(event); err != nil { + log.Errorf("json encoding error while streaming %v", err.Error()) + } else { + fmt.Fprintf(w, "event: container-stopped\ndata: %s\n\n", buf) f.Flush() - } else if err != context.Canceled { - log.Errorf("unknown error while streaming %v", err.Error()) } + + case <-r.Context().Done(): + log.Debugf("context cancelled") + break loop } - default: } if log.IsLevelEnabled(log.DebugLevel) { var m runtime.MemStats runtime.ReadMemStats(&m) - // For info on each, see: https://golang.org/pkg/runtime/#MemStats log.WithFields(log.Fields{ "allocated": humanize.Bytes(m.Alloc), "totalAllocated": humanize.Bytes(m.TotalAlloc), diff --git a/internal/web/logs_test.go b/internal/web/logs_test.go index 91490d9c..ce8bd97c 100644 --- a/internal/web/logs_test.go +++ b/internal/web/logs_test.go @@ -2,6 +2,7 @@ package web import ( "bytes" + "context" "encoding/binary" "errors" "io" @@ -19,8 +20,11 @@ import ( ) func Test_handler_streamLogs_happy(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + id := "123456" - req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) + req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) + q := req.URL.Query() q.Add("stdout", "true") q.Add("stderr", "true") @@ -32,8 +36,14 @@ func Test_handler_streamLogs_happy(t *testing.T) { data := makeMessage("INFO Testing logs...", docker.STDOUT) - mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Tty: false}, nil) - mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil) + mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Tty: false, Host: "localhost"}, nil) + mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil). + Run(func(args mock.Arguments) { + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + }) handler := createDefaultHandler(mockedClient) rr := httptest.NewRecorder() @@ -44,7 +54,8 @@ func Test_handler_streamLogs_happy(t *testing.T) { func Test_handler_streamLogs_happy_with_id(t *testing.T) { id := "123456" - req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) q := req.URL.Query() q.Add("stdout", "true") q.Add("stderr", "true") @@ -56,8 +67,14 @@ func Test_handler_streamLogs_happy_with_id(t *testing.T) { data := makeMessage("2020-05-13T18:55:37.772853839Z INFO Testing logs...", docker.STDOUT) - mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) - mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil) + mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) + mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil). + Run(func(args mock.Arguments) { + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + }) handler := createDefaultHandler(mockedClient) rr := httptest.NewRecorder() @@ -68,7 +85,8 @@ func Test_handler_streamLogs_happy_with_id(t *testing.T) { func Test_handler_streamLogs_happy_container_stopped(t *testing.T) { id := "123456" - req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) q := req.URL.Query() q.Add("stdout", "true") q.Add("stderr", "true") @@ -77,8 +95,14 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) { require.NoError(t, err, "NewRequest should not return an error.") mockedClient := new(MockedClient) - mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) - mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF) + mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) + mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF). + Run(func(args mock.Arguments) { + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + }) handler := createDefaultHandler(mockedClient) rr := httptest.NewRecorder() @@ -89,7 +113,8 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) { func Test_handler_streamLogs_error_finding_container(t *testing.T) { id := "123456" - req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) q := req.URL.Query() q.Add("stdout", "true") q.Add("stderr", "true") @@ -98,7 +123,13 @@ func Test_handler_streamLogs_error_finding_container(t *testing.T) { require.NoError(t, err, "NewRequest should not return an error.") mockedClient := new(MockedClient) - mockedClient.On("FindContainer", id).Return(docker.Container{}, errors.New("error finding container")) + mockedClient.On("FindContainer", id).Return(docker.Container{}, errors.New("error finding container")). + Run(func(args mock.Arguments) { + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + }) handler := createDefaultHandler(mockedClient) rr := httptest.NewRecorder() @@ -108,8 +139,10 @@ func Test_handler_streamLogs_error_finding_container(t *testing.T) { } func Test_handler_streamLogs_error_reading(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + id := "123456" - req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) + req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil) q := req.URL.Query() q.Add("stdout", "true") q.Add("stderr", "true") @@ -118,8 +151,14 @@ func Test_handler_streamLogs_error_reading(t *testing.T) { require.NoError(t, err, "NewRequest should not return an error.") mockedClient := new(MockedClient) - mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) - mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), errors.New("test error")) + mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) + mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), errors.New("test error")). + Run(func(args mock.Arguments) { + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + }) handler := createDefaultHandler(mockedClient) rr := httptest.NewRecorder() @@ -135,6 +174,7 @@ func Test_handler_streamLogs_error_std(t *testing.T) { require.NoError(t, err, "NewRequest should not return an error.") mockedClient := new(MockedClient) + mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) handler := createDefaultHandler(mockedClient) rr := httptest.NewRecorder() diff --git a/internal/web/routes.go b/internal/web/routes.go index f1c2ba3c..bd0bb06e 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -90,9 +90,13 @@ func createRouter(h *handler) *chi.Mux { if h.config.Authorization.Provider != NONE { r.Use(auth.RequireAuthentication) } - r.Get("/api/hosts/{host}/containers/{id}/logs/stream", h.streamLogs) + r.Get("/api/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs) r.Get("/api/hosts/{host}/containers/{id}/logs/download", h.downloadLogs) r.Get("/api/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates) + r.Get("/api/hosts/{host}/logs/mergedStream", h.streamLogsMerged) + r.Get("/api/stacks/{stack}/logs/stream", h.streamStackLogs) + r.Get("/api/services/{service}/logs/stream", h.streamServiceLogs) + r.Get("/api/groups/{group}/logs/stream", h.streamGroupedLogs) r.Get("/api/events/stream", h.streamEvents) if h.config.EnableActions { r.Post("/api/hosts/{host}/containers/{id}/actions/{action}", h.containerActions) diff --git a/locales/en.yml b/locales/en.yml index 038f0e6f..bbd65e38 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -10,6 +10,7 @@ toolbar: label: containers: Containers container: No containers | 1 container | {count} containers + serivce: No services | 1 service | {count} services running-containers: Running Containers all-containers: All Containers host: Host @@ -22,7 +23,9 @@ label: avg-mem: Avg. MEM (%) pinned: Pinned per-page: Rows per page - + swarm-mode: Swam Mode + services: Services + custom-groups: Custom Groups tooltip: search: Search containers (⌘ + k, ⌃k) pin-column: Pin as column diff --git a/package.json b/package.json index 8e43837c..9c288137 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "typescript": "^5.4.5", "vitepress": "1.2.2", "vitest": "^1.6.0", + "vue-component-type-helpers": "^2.0.19", "vue-tsc": "^2.0.19" }, "lint-staged": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2889d95..fe65feec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: vitest: specifier: ^1.6.0 version: 1.6.0(@types/node@20.12.12)(jsdom@24.0.0) + vue-component-type-helpers: + specifier: ^2.0.19 + version: 2.0.19 vue-tsc: specifier: ^2.0.19 version: 2.0.19(typescript@5.4.5) @@ -3293,8 +3296,8 @@ packages: jsdom: optional: true - vue-component-type-helpers@2.0.11: - resolution: {integrity: sha512-8aluKz5oVC8PvVQAYgyIefOlqzKVmAOTCx2imbrFBVLbF7mnJvyMsE2A7rqX/4f4uT6ee9o8u3GcoRpUWc0xsw==} + vue-component-type-helpers@2.0.19: + resolution: {integrity: sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==} vue-demi@0.14.7: resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} @@ -4546,7 +4549,7 @@ snapshots: '@vue/test-utils@2.4.6': dependencies: js-beautify: 1.15.1 - vue-component-type-helpers: 2.0.11 + vue-component-type-helpers: 2.0.19 '@vueuse/components@10.9.0(vue@3.4.27(typescript@5.4.5))': dependencies: @@ -6693,7 +6696,7 @@ snapshots: - supports-color - terser - vue-component-type-helpers@2.0.11: {} + vue-component-type-helpers@2.0.19: {} vue-demi@0.14.7(vue@3.4.27(typescript@5.4.5)): dependencies: