1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-24 06:28:42 +01:00

feat: supports swarm mode with stacks and services on remote hosts 🙌🏼 (#2961)

This commit is contained in:
Amir Raminfar
2024-05-23 10:17:16 -07:00
committed by GitHub
parent 68908e57b7
commit ba0206a903
89 changed files with 1931 additions and 1000 deletions

View File

@@ -58,6 +58,8 @@ declare global {
const getDeep: typeof import('./utils/index')['getDeep'] const getDeep: typeof import('./utils/index')['getDeep']
const globalShowPopup: typeof import('./composable/popup')['globalShowPopup'] const globalShowPopup: typeof import('./composable/popup')['globalShowPopup']
const h: typeof import('vue')['h'] 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 hourStyle: typeof import('./stores/settings')['hourStyle']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject'] const inject: typeof import('vue')['inject']
@@ -71,7 +73,10 @@ declare global {
const isRef: typeof import('vue')['isRef'] const isRef: typeof import('vue')['isRef']
const lightTheme: typeof import('./stores/settings')['lightTheme'] const lightTheme: typeof import('./stores/settings')['lightTheme']
const locale: typeof import('./stores/settings')['locale'] 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 logSearchContext: typeof import('./composable/logSearchContext')['logSearchContext']
const loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions'] const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters'] const mapGetters: typeof import('pinia')['mapGetters']
@@ -102,10 +107,14 @@ declare global {
const onUpdated: typeof import('vue')['onUpdated'] const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const persistentVisibleKeys: typeof import('./composable/storage')['persistentVisibleKeys'] const persistentVisibleKeys: typeof import('./composable/storage')['persistentVisibleKeys']
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers'] const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
const provide: typeof import('vue')['provide'] const provide: typeof import('vue')['provide']
const provideContainerContext: typeof import('./composable/containerContext')['provideContainerContext'] const provideContainerContext: typeof import('./composable/containerContext')['provideContainerContext']
const provideLocal: typeof import('@vueuse/core')['provideLocal'] 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 reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive'] const reactive: typeof import('vue')['reactive']
@@ -123,6 +132,7 @@ declare global {
const resolveRef: typeof import('@vueuse/core')['resolveRef'] const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const search: typeof import('./stores/settings')['search'] const search: typeof import('./stores/settings')['search']
const serviceContext: typeof import('./composable/serviceContext')['serviceContext']
const sessionHost: typeof import('./composable/storage')['sessionHost'] const sessionHost: typeof import('./composable/storage')['sessionHost']
const setActivePinia: typeof import('pinia')['setActivePinia'] const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
@@ -137,6 +147,7 @@ declare global {
const size: typeof import('./stores/settings')['size'] const size: typeof import('./stores/settings')['size']
const smallerScrollbars: typeof import('./stores/settings')['smallerScrollbars'] const smallerScrollbars: typeof import('./stores/settings')['smallerScrollbars']
const softWrap: typeof import('./stores/settings')['softWrap'] const softWrap: typeof import('./stores/settings')['softWrap']
const stackContext: typeof import('./composable/stackContext')['stackContext']
const storeToRefs: typeof import('pinia')['storeToRefs'] const storeToRefs: typeof import('pinia')['storeToRefs']
const stripVersion: typeof import('./utils/index')['stripVersion'] const stripVersion: typeof import('./utils/index')['stripVersion']
const syncRef: typeof import('@vueuse/core')['syncRef'] const syncRef: typeof import('@vueuse/core')['syncRef']
@@ -189,7 +200,9 @@ declare global {
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useContainerActions: typeof import('./composable/containerActions')['useContainerActions'] const useContainerActions: typeof import('./composable/containerActions')['useContainerActions']
const useContainerContext: typeof import('./composable/containerContext')['useContainerContext'] const useContainerContext: typeof import('./composable/containerContext')['useContainerContext']
const useContainerContextLogStream: typeof import('./composable/eventStreams')['useContainerContextLogStream']
const useContainerStore: typeof import('./stores/container')['useContainerStore'] const useContainerStore: typeof import('./stores/container')['useContainerStore']
const useContainerStream: typeof import('./composable/eventStreams')['useContainerStream']
const useCounter: typeof import('@vueuse/core')['useCounter'] const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule'] const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar'] const useCssVar: typeof import('@vueuse/core')['useCssVar']
@@ -229,6 +242,7 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad'] const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
const useHead: typeof import('@vueuse/head')['useHead'] const useHead: typeof import('@vueuse/head')['useHead']
const useHosts: typeof import('./stores/hosts')['useHosts'] const useHosts: typeof import('./stores/hosts')['useHosts']
const useI18n: typeof import('vue-i18n')['useI18n'] const useI18n: typeof import('vue-i18n')['useI18n']
@@ -243,13 +257,15 @@ declare global {
const useLink: typeof import('vue-router')['useLink'] const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage'] const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useLogSearchContext: typeof import('./composable/logSearchContext')['useLogSearchContext'] 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 useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory'] const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls'] const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery'] const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize'] const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory'] const useMemory: typeof import('@vueuse/core')['useMemory']
const useMergedStream: typeof import('./composable/eventStreams')['useMergedStream']
const useMounted: typeof import('@vueuse/core')['useMounted'] const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse'] const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
@@ -266,6 +282,10 @@ declare global {
const useParentElement: typeof import('@vueuse/core')['useParentElement'] const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver'] const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission'] 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 usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock'] const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe'] const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
@@ -289,6 +309,9 @@ declare global {
const useScrollLock: typeof import('@vueuse/core')['useScrollLock'] const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSearchFilter: typeof import('./composable/search')['useSearchFilter'] const useSearchFilter: typeof import('./composable/search')['useSearchFilter']
const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta'] 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 useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare'] const useShare: typeof import('@vueuse/core')['useShare']
const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory'] const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory']
@@ -296,11 +319,16 @@ declare global {
const useSorted: typeof import('@vueuse/core')['useSorted'] const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] 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 useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage'] const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync'] const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag'] const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported'] const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwarmStore: typeof import('./stores/swarm')['useSwarmStore']
const useSwipe: typeof import('@vueuse/core')['useSwipe'] const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList'] const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection'] const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
@@ -417,6 +445,7 @@ declare module 'vue' {
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']> readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']> readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly h: UnwrapRef<typeof import('vue')['h']> readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']> readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']> readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']> readonly inject: UnwrapRef<typeof import('vue')['inject']>
@@ -430,6 +459,7 @@ declare module 'vue' {
readonly isRef: UnwrapRef<typeof import('vue')['isRef']> readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']> readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']> 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 makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']> readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']> readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -459,11 +489,12 @@ declare module 'vue' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']> readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> 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 pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']> readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']> 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 reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']> readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']> readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -548,6 +579,7 @@ declare module 'vue' {
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']> readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']> readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']>
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']> 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 useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']> readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']> readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
@@ -587,6 +619,7 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']> readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']> readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']> 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 useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']> readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']> readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
@@ -601,13 +634,14 @@ declare module 'vue' {
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']> readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']> readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']> 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 useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']> readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']> readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']> readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']> readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']> 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 useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']> readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']> readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -624,6 +658,7 @@ declare module 'vue' {
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']> readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']> readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']> 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 usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']> readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']> readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
@@ -647,6 +682,7 @@ declare module 'vue' {
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']> readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']> readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']>
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']> 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 useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']> readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']> readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
@@ -654,11 +690,13 @@ declare module 'vue' {
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']> readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']> readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']> 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 useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']> readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']> readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']> readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']> 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 useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']> readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']> readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
@@ -768,6 +806,7 @@ declare module '@vue/runtime-core' {
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']> readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']> readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly h: UnwrapRef<typeof import('vue')['h']> readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']> readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']> readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']> readonly inject: UnwrapRef<typeof import('vue')['inject']>
@@ -781,6 +820,7 @@ declare module '@vue/runtime-core' {
readonly isRef: UnwrapRef<typeof import('vue')['isRef']> readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']> readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']> 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 makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']> readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']> readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -810,11 +850,12 @@ declare module '@vue/runtime-core' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']> readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> 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 pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']> readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']> 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 reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']> readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']> readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -899,6 +940,7 @@ declare module '@vue/runtime-core' {
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']> readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']> readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']>
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']> 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 useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']> readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']> readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
@@ -938,6 +980,7 @@ declare module '@vue/runtime-core' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']> readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']> readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']> 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 useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']> readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']> readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
@@ -952,13 +995,14 @@ declare module '@vue/runtime-core' {
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']> readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']> readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']> 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 useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']> readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']> readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']> readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']> readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']> 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 useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']> readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']> readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -975,6 +1019,7 @@ declare module '@vue/runtime-core' {
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']> readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']> readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']> 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 usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']> readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']> readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
@@ -998,6 +1043,7 @@ declare module '@vue/runtime-core' {
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']> readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']> readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']>
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']> 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 useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']> readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']> readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
@@ -1005,11 +1051,13 @@ declare module '@vue/runtime-core' {
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']> readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']> readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']> 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 useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']> readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']> readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']> readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']> 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 useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']> readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']> readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>

View File

@@ -7,7 +7,6 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default'] 'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default'] 'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['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:columns': typeof import('~icons/cil/columns')['default']
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default'] 'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default'] ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerHealth: typeof import('./components/LogViewer/ContainerHealth.vue')['default'] ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerLogViewer: typeof import('./components/LogViewer/ContainerLogViewer.vue')['default'] ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
ContainerPopup: typeof import('./components/LogViewer/ContainerPopup.vue')['default'] ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default'] ContainerName: typeof import('./components/LogViewer/ContainerName.vue')['default']
ContainerPopup: typeof import('./components/ContainerPopup.vue')['default']
ContainerTable: typeof import('./components/ContainerTable.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'] DateTime: typeof import('./components/common/DateTime.vue')['default']
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default'] DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default'] DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default']
Dropdown: typeof import('./components/common/Dropdown.vue')['default'] Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.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'] FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default'] FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
HostList: typeof import('./components/HostList.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'] 'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default'] InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default'] KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default'] LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
Links: typeof import('./components/Links.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'] LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default']
LogLevel: typeof import('./components/LogViewer/LogLevel.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'] LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default'] LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.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:collapseAllRounded': typeof import('~icons/material-symbols/collapse-all-rounded')['default']
'MaterialSymbols:expandAllRounded': typeof import('~icons/material-symbols/expand-all-rounded')['default'] 'MaterialSymbols:expandAllRounded': typeof import('~icons/material-symbols/expand-all-rounded')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['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:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default'] 'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['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:container24': typeof import('~icons/octicon/container24')['default']
'Octicon:download24': typeof import('~icons/octicon/download24')['default'] 'Octicon:download24': typeof import('~icons/octicon/download24')['default']
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default'] 'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default'] PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
'Ph:circlesFour': typeof import('~icons/ph/circles-four')['default']
'Ph:command': typeof import('~icons/ph/command')['default'] 'Ph:command': typeof import('~icons/ph/command')['default']
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default'] 'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default'] 'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:cpu': typeof import('~icons/ph/cpu')['default'] 'Ph:cpu': typeof import('~icons/ph/cpu')['default']
'Ph:memory': typeof import('~icons/ph/memory')['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'] Popup: typeof import('./components/Popup.vue')['default']
Releases: typeof import('./components/Releases.vue')['default'] Releases: typeof import('./components/Releases.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
@@ -83,15 +90,20 @@ declare module 'vue' {
ScrollableView: typeof import('./components/ScrollableView.vue')['default'] ScrollableView: typeof import('./components/ScrollableView.vue')['default']
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default'] ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default'] Search: typeof import('./components/Search.vue')['default']
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default'] SideMenu: typeof import('./components/SideMenu.vue')['default']
SidePanel: typeof import('./components/SidePanel.vue')['default'] SidePanel: typeof import('./components/SidePanel.vue')['default']
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default'] SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.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'] StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
StatSparkline: typeof import('./components/LogViewer/StatSparkline.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'] Tag: typeof import('./components/common/Tag.vue')['default']
TimedButton: typeof import('./components/common/TimedButton.vue')['default'] TimedButton: typeof import('./components/common/TimedButton.vue')['default']
Toggle: typeof import('./components/common/Toggle.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'] ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
} }
} }

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>

View File

@@ -2,7 +2,7 @@
<div> <div>
<span class="font-light capitalize"> RUNNING </span> <span class="font-light capitalize"> RUNNING </span>
<span class="font-semibold"> <span class="font-semibold">
<distance-time :date="container.created" strict :suffix="false"></distance-time> <DistanceTime :date="container.created" strict :suffix="false" />
</span> </span>
</div> </div>
<div> <div>

View File

@@ -8,7 +8,7 @@
<li> <li>
<a @click.prevent="clear()"> <a @click.prevent="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }} <octicon:trash-24 /> {{ $t("toolbar.clear") }}
<key-shortcut char="k" :modifiers="['shift', 'meta']"></key-shortcut> <KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a> </a>
</li> </li>
<li> <li>
@@ -17,7 +17,7 @@
<li> <li>
<a @click.prevent="showSearch = true"> <a @click.prevent="showSearch = true">
<mdi:magnify /> {{ $t("toolbar.search") }} <mdi:magnify /> {{ $t("toolbar.search") }}
<key-shortcut char="f"></key-shortcut> <KeyShortcut char="f" />
</a> </a>
</li> </li>
<li class="line"></li> <li class="line"></li>
@@ -101,15 +101,18 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Container } from "@/models/Container";
const { showSearch } = useSearchFilter(); const { showSearch } = useSearchFilter();
const { enableActions } = config; const { enableActions } = config;
const clear = defineEmit(); const clear = defineEmit();
const { container, streamConfig } = useContainerContext(); const { streamConfig } = useLoggingContext();
// container context is provided in the parent component: <LogContainer> const { container } = defineProps<{ container: Container }>();
const { actionStates, start, stop, restart } = useContainerActions();
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
const downloadParams = computed(() => const downloadParams = computed(() =>
Object.entries(streamConfig) Object.entries(streamConfig)
@@ -119,7 +122,7 @@ const downloadParams = computed(() =>
const downloadUrl = computed(() => const downloadUrl = computed(() =>
withBase( withBase(
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`, `/api/hosts/${container.host}/containers/${container.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
), ),
); );

View File

@@ -0,0 +1,54 @@
<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>>();
provideContainerContext(container); // TODO remove
provideLoggingContext(toRef(() => [container.value]));
</script>

View File

@@ -17,22 +17,24 @@
{{ container.swarmId }} {{ container.swarmId }}
</div> </div>
</div> </div>
<container-health :health="container.health" v-if="container.health"></container-health> <ContainerHealth :health="container.health" v-if="container.health" />
<tag class="mobile-hidden hidden font-mono @3xl:block" size="small"> <Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
{{ container.image.replace(/@sha.*/, "") }} {{ container.image.replace(/@sha.*/, "") }}
</tag> </Tag>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const { container } = useContainerContext(); import { Container } from "@/models/Container";
const { container } = defineProps<{ container: Container }>();
const pinned = computed({ const pinned = computed({
get: () => pinnedContainers.value.has(container.value.name), get: () => pinnedContainers.value.has(container.name),
set: (value) => { set: (value) => {
if (value) { if (value) {
pinnedContainers.value.add(container.value.name); pinnedContainers.value.add(container.name);
} else { } else {
pinnedContainers.value.delete(container.value.name); pinnedContainers.value.delete(container.name);
} }
}, },
}); });

View File

@@ -32,7 +32,7 @@
</template> </template>
<span data-name v-html="matchedName(result)"></span> <span data-name v-html="matchedName(result)"></span>
</div> </div>
<distance-time :date="result.item.created" class="text-xs font-light" /> <DistanceTime :date="result.item.created" class="text-xs font-light" />
<a <a
@click.stop.prevent="addColumn(result.item)" @click.stop.prevent="addColumn(result.item)"
:title="$t('tooltip.pin-column')" :title="$t('tooltip.pin-column')"
@@ -62,8 +62,9 @@ const input = ref<HTMLInputElement>();
const selectedIndex = ref(0); const selectedIndex = ref(0);
const router = useRouter(); const router = useRouter();
const store = useContainerStore(); const containerStore = useContainerStore();
const { containers } = storeToRefs(store); const pinnedStore = usePinnedLogsStore();
const { containers } = storeToRefs(containerStore);
const list = computed(() => { const list = computed(() => {
return containers.value.map(({ id, created, name, state, labels, hostLabel: host }) => { return containers.value.map(({ id, created, name, state, labels, hostLabel: host }) => {
@@ -121,7 +122,7 @@ function selected({ id }: { id: string }) {
close(); close();
} }
function addColumn(container: { id: string }) { function addColumn(container: { id: string }) {
store.appendActiveContainer(container); pinnedStore.pinContainer(container);
close(); close();
} }

View File

@@ -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>

View File

@@ -30,11 +30,12 @@
{{ label.startsWith("label.") ? $t(label) : label }} {{ label.startsWith("label.") ? $t(label) : label }}
<router-link <router-link
:to="{ name: 'stack-name', params: { name: label } }" :to="{ name: 'merged', query: { id: containers.map(({ id }) => id) } }"
class="btn btn-info btn-xs" class="btn btn-square btn-outline btn-primary btn-xs"
v-if="!label.startsWith('label.')" active-class="btn-active"
title="Merge all containers into one view"
> >
all <ph:arrows-merge />
</router-link> </router-link>
</summary> </summary>
<ul> <ul>
@@ -43,7 +44,7 @@
<router-link <router-link
:to="{ name: 'container-id', params: { id: item.id } }" :to="{ name: 'container-id', params: { id: item.id } }"
active-class="active-primary" active-class="active-primary"
@click.alt.stop.prevent="store.appendActiveContainer(item)" @click.alt.stop.prevent="pinnedStore.pinContainer(item)"
:title="item.name" :title="item.name"
> >
<div class="truncate"> <div class="truncate">
@@ -52,8 +53,8 @@
<ContainerHealth :health="item.health" /> <ContainerHealth :health="item.health" />
<span <span
class="pin" class="pin"
@click.stop.prevent="store.appendActiveContainer(item)" @click.stop.prevent="pinnedStore.pinContainer(item)"
v-show="!activeContainersById[item.id]" v-show="!pinnedStore.isPinned(item)"
:title="$t('tooltip.pin-column')" :title="$t('tooltip.pin-column')"
> >
<cil:columns /> <cil:columns />
@@ -83,9 +84,11 @@ import Stack from "~icons/ph/stack";
// @ts-ignore // @ts-ignore
import Containers from "~icons/octicon/container-24"; 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 { hosts } = useHosts();
const setHost = (host: string | null) => (sessionHost.value = host); const setHost = (host: string | null) => (sessionHost.value = host);
@@ -121,7 +124,7 @@ const menuItems = computed(() => {
const singular = []; const singular = [];
for (const item of sortedContainers.value) { 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)) { if (debouncedPinnedContainers.value.has(item.name)) {
pinned.push(item); pinned.push(item);
} else if (namespace) { } else if (namespace) {
@@ -156,16 +159,6 @@ const menuItems = computed(() => {
return items; return items;
}); });
const activeContainersById = computed(() =>
activeContainers.value.reduce(
(acc, item) => {
acc[item.id] = item;
return acc;
},
{} as Record<string, Container>,
),
);
</script> </script>
<style scoped lang="postcss"> <style scoped lang="postcss">
.menu { .menu {

View File

@@ -14,6 +14,7 @@ const isLoading = ref(false);
const root = ref<HTMLElement>(); const root = ref<HTMLElement>();
const observer = new IntersectionObserver(async (entries) => { const observer = new IntersectionObserver(async (entries) => {
// console.log(entries, entries[0].intersectionRatio);
if (entries[0].intersectionRatio <= 0) return; if (entries[0].intersectionRatio <= 0) return;
if (onLoadMore && enabled) { if (onLoadMore && enabled) {
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement; const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex items-center justify-end gap-4"> <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> <template #trigger>
<mdi:announcement class="size-6 -rotate-12" /> <mdi:announcement class="size-6 -rotate-12" />
<span <span
@@ -10,10 +10,10 @@
</template> </template>
<template #content> <template #content>
<div class="w-72"> <div class="w-72">
<releases /> <Releases />
</div> </div>
</template> </template>
</dropdown> </Dropdown>
<router-link <router-link
:to="{ name: 'settings' }" :to="{ name: 'settings' }"

View File

@@ -5,14 +5,17 @@
<material-symbols:expand-all-rounded class="swap-off text-secondary" /> <material-symbols:expand-all-rounded class="swap-off text-secondary" />
<material-symbols:collapse-all-rounded class="swap-on text-secondary" /> <material-symbols:collapse-all-rounded class="swap-on text-secondary" />
</label> </label>
<div v-if="showContainerName">
<ContainerName :id="logEntry.containerID" />
</div>
<div v-if="showStd"> <div v-if="showStd">
<log-std :std="logEntry.std"></log-std> <LogStd :std="logEntry.std" />
</div> </div>
<div v-if="showTimestamp"> <div v-if="showTimestamp">
<log-date :date="logEntry.date"></log-date> <LogDate :date="logEntry.date" />
</div> </div>
<div class="flex"> <div class="flex">
<log-level :level="logEntry.level"></log-level> <LogLevel :level="logEntry.level" />
</div> </div>
<div> <div>
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }"> <ul class="fields cursor-pointer space-x-4" :class="{ expanded }">
@@ -25,9 +28,9 @@
</li> </li>
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li> <li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
</ul> </ul>
<field-list :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys"></field-list> <FieldList :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys" />
</div> </div>
<log-message-actions <LogMessageActions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100" class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
:message="() => JSON.stringify(logEntry.message)" :message="() => JSON.stringify(logEntry.message)"
:log-entry="logEntry" :log-entry="logEntry"
@@ -39,9 +42,10 @@ import { type ComplexLogEntry } from "@/models/LogEntry";
const { markSearch } = useSearchFilter(); const { markSearch } = useSearchFilter();
const { logEntry } = defineProps<{ const { logEntry, showContainerName = false } = defineProps<{
logEntry: ComplexLogEntry; logEntry: ComplexLogEntry;
visibleKeys: string[][]; visibleKeys: string[][];
showContainerName?: boolean;
}>(); }>();
const [expanded, expandToggle] = useToggle(); const [expanded, expandToggle] = useToggle();

View File

@@ -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>

View File

@@ -0,0 +1,45 @@
<template>
<div class="relative block w-40 overflow-hidden rounded px-1.5 text-center text-sm text-white">
<div class="random-color absolute inset-0 brightness-75"></div>
<div class="direction-rtl relative truncate">{{ containerNames[id] }}</div>
</div>
</template>
<script lang="ts" setup>
const containerStore = useContainerStore();
const { containerNames } = storeToRefs(containerStore);
const { id } = defineProps<{
id: string;
}>();
const colors = [
"#FF0000",
"#FF7F00",
"#FFFF00",
"#00FF00",
"#00FFFF",
"#0000FF",
"#FF00FF",
"#FF007F",
"#32CD32",
"#40E0D0",
"#800080",
"#FFD700",
"#FF4040",
"#4B0082",
"#008080",
"#E6E6FA",
];
const color = computed(() => colors[Math.abs(hashCode(id)) % colors.length]);
</script>
<style lang="postcss" scoped>
.random-color {
background-color: v-bind(color);
}
.direction-rtl {
direction: rtl;
}
</style>

View File

@@ -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>

View File

@@ -1,25 +1,33 @@
<template> <template>
<div class="flex-1 font-sans text-[0.9rem]"> <div class="relative flex w-full items-start gap-x-2">
<span class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></span> <ContainerName class="flex-none" :id="logEntry.containerID" v-if="showContainerName" />
<div <LogDate :date="logEntry.date" v-if="showTimestamp" />
class="alert alert-info mt-8 w-auto text-[1rem] md:mx-auto md:w-1/2" <LogLevel class="flex" />
v-if="nextContainer && logEntry.event === 'container-stopped'" <div class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></div>
> </div>
<carbon:information class="size-6 shrink-0 stroke-current" /> <div
<div> class="alert alert-info mt-8 w-auto text-[1rem] md:mx-auto md:w-1/2"
<h3 class="text-lg font-bold">{{ $t("alert.similar-container-found.title") }}</h3> v-if="nextContainer && logEntry.event === 'container-stopped'"
{{ $t("alert.similar-container-found.message", { containerId: nextContainer.id }) }} >
</div> <carbon:information class="size-6 shrink-0 stroke-current" />
<div> <div>
<TimedButton v-if="automaticRedirect" class="btn-primary btn-sm" @finished="redirectNow()">Cancel</TimedButton> <h3 class="text-lg font-bold">{{ $t("alert.similar-container-found.title") }}</h3>
<router-link {{ $t("alert.similar-container-found.message", { containerId: nextContainer.id }) }}
:to="{ name: 'container-id', params: { id: nextContainer.id } }" </div>
class="btn btn-primary btn-sm" <div>
v-else <TimedButton
> v-if="automaticRedirect && containers.length == 1"
Redirect class="btn-primary btn-sm"
</router-link> @finished="redirectNow()"
</div> >Cancel</TimedButton
>
<router-link
:to="{ name: 'container-id', params: { id: nextContainer.id } }"
class="btn btn-primary btn-sm"
v-else
>
Redirect
</router-link>
</div> </div>
</div> </div>
</template> </template>
@@ -31,20 +39,19 @@ const { t } = useI18n();
const { logEntry } = defineProps<{ const { logEntry } = defineProps<{
logEntry: DockerEventLogEntry; logEntry: DockerEventLogEntry;
showContainerName?: boolean;
}>(); }>();
const store = useContainerStore(); const { containers } = useLoggingContext();
const { containers } = storeToRefs(store);
const { container } = useContainerContext();
const nextContainer = computed( const nextContainer = computed(
() => () =>
[ [
...containers.value.filter( ...containers.value.filter(
(c) => (c) =>
c.host === container.value.host && c.host === containers.value[0].host &&
c.created > logEntry.date && c.created > logEntry.date &&
c.name === container.value.name && c.name === containers.value[0].name &&
c.state === "running", c.state === "running",
), ),
].sort((a, b) => +a.created - +b.created)[0], ].sort((a, b) => +a.created - +b.created)[0],

View File

@@ -1,6 +1,5 @@
import { createTestingPinia } from "@pinia/testing"; import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import { containerContext } from "@/composable/containerContext";
import { useSearchFilter } from "@/composable/search"; import { useSearchFilter } from "@/composable/search";
import { settings } from "@/stores/settings"; import { settings } from "@/stores/settings";
// @ts-ignore // @ts-ignore
@@ -9,8 +8,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { computed, nextTick } from "vue"; import { computed, nextTick } from "vue";
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import LogEventSource from "./LogEventSource.vue"; import { default as Component } from "./EventSource.vue";
import ContainerLogViewer from "./ContainerLogViewer.vue"; import LogViewer from "@/components/LogViewer/LogViewer.vue";
import { Container } from "@/models/Container";
vi.mock("@/stores/config", () => ({ vi.mock("@/stores/config", () => ({
__esModule: true, __esModule: true,
@@ -21,7 +21,7 @@ vi.mock("@/stores/config", () => ({
/** /**
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
describe("<LogEventSource />", () => { describe("<ContainerEventSource />", () => {
const search = useSearchFilter(); const search = useSearchFilter();
beforeEach(() => { beforeEach(() => {
@@ -67,26 +67,29 @@ describe("<LogEventSource />", () => {
], ],
}); });
return mount(LogEventSource, { return mount(Component<Container>, {
global: { global: {
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})], plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
components: { components: {
ContainerLogViewer, LogViewer,
}, },
provide: { provide: {
[containerContext as symbol]: { scrollingPaused: computed(() => false),
container: computed(() => ({ id: "abc", image: "test:v123", host: "localhost" })), [loggingContextKey as symbol]: {
containers: computed(() => [{ id: "abc", image: "test:v123", host: "localhost" }]),
streamConfig: reactive({ stdout: true, stderr: true }), streamConfig: reactive({ stdout: true, stderr: true }),
}, },
scrollingPaused: computed(() => false),
}, },
}, },
slots: { slots: {
default: ` 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", []),
},
}); });
} }

View File

@@ -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>

View File

@@ -3,12 +3,7 @@
<li v-for="(value, name) in fields" :key="name"> <li v-for="(value, name) in fields" :key="name">
<template v-if="isObject(value)"> <template v-if="isObject(value)">
<span class="text-light">{{ name }}=</span> <span class="text-light">{{ name }}=</span>
<field-list <FieldList :fields="value" :parent-key="parentKey.concat(name)" :visible-keys="visibleKeys" expanded />
:fields="value"
:parent-key="parentKey.concat(name)"
:visible-keys="visibleKeys"
expanded
></field-list>
</template> </template>
<template v-else-if="Array.isArray(value)"> <template v-else-if="Array.isArray(value)">
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer"> <a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
<template> <template>
<tag size="small"> <Tag size="small">
<date-time :date="date" class="whitespace-nowrap text-blue"></date-time> <DateTime :date="date" class="whitespace-nowrap text-blue" />
</tag> </Tag>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ defineProps<{

View File

@@ -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>

View File

@@ -0,0 +1,73 @@
<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"
:show-container-name="showContainerName"
/>
</li>
</ul>
</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>

View File

@@ -1,7 +1,7 @@
<template> <template>
<tag size="small" :std="std"> <Tag size="small" :std="std">
{{ std }} {{ std }}
</tag> </Tag>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View File

@@ -1,67 +1,43 @@
<template> <template>
<ul class="events group py-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }"> <LogList
<li :messages="filtered"
v-for="item in messages" :last-selected-item="lastSelectedItem"
:key="item.id" :visible-keys="visibleKeys"
:data-key="item.id" :show-container-name="showContainerName"
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }" />
class="group/entry"
>
<component :is="item.getComponent()" :log-entry="item" :visible-keys="visibleKeys" />
</li>
</ul>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { toRaw } from "vue"; import { useRouteHash } from "@vueuse/router";
import { type JSONObject, LogEntry } from "@/models/LogEntry"; import { type JSONObject, LogEntry } from "@/models/LogEntry";
defineProps<{ const props = defineProps<{
messages: LogEntry<string | JSONObject>[]; messages: LogEntry<string | JSONObject>[];
visibleKeys: string[][]; 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> </script>
<style scoped lang="postcss"> <style scoped lang="postcss"></style>
.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>

View File

@@ -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>

View File

@@ -0,0 +1,78 @@
<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 });
let history = 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);
}
history = useSimpleRefHistory(totalStat, { capacity: 300, 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),
})),
);
// watch(memoryData, () => {
// console.log(memoryData.value);
// });
</script>

View File

@@ -1,13 +1,14 @@
<template> <template>
<div class="relative flex w-full items-start gap-x-2"> <div class="relative flex w-full items-start gap-x-2">
<log-std :std="logEntry.std" v-if="showStd" /> <LogStd :std="logEntry.std" v-if="showStd" />
<log-date :date="logEntry.date" v-if="showTimestamp" /> <ContainerName class="flex-none" :id="logEntry.containerID" v-if="showContainerName" />
<log-level class="flex" :level="logEntry.level" :position="logEntry.position" /> <LogDate :date="logEntry.date" v-if="showTimestamp" />
<LogLevel class="flex" :level="logEntry.level" :position="logEntry.position" />
<div <div
class="whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap" class="whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap"
v-html="colorize(logEntry.message)" v-html="colorize(logEntry.message)"
></div> ></div>
<log-message-actions <LogMessageActions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100" class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
:message="() => decodeXML(stripAnsi(logEntry.message))" :message="() => decodeXML(stripAnsi(logEntry.message))"
:log-entry="logEntry" :log-entry="logEntry"
@@ -26,8 +27,9 @@ const ansiConvertor = new AnsiConvertor({
bg: "var(--base-color)", bg: "var(--base-color)",
}); });
defineProps<{ const { showContainerName = false } = defineProps<{
logEntry: SimpleLogEntry; logEntry: SimpleLogEntry;
showContainerName?: boolean;
}>(); }>();
const { markSearch } = useSearchFilter(); const { markSearch } = useSearchFilter();

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="my-4 flex-1 text-center"> <div class="my-4 flex-1 text-center">
<div class="relative"> <div class="relative">
<zig-zag class="absolute inset-0 mt-2"></zig-zag> <ZigZag class="absolute inset-0 mt-2" />
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">{{ <span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">
$t("error.logs-skipped", { total: logEntry.totalSkipped }) {{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
}}</span> </span>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="relative hover:text-secondary" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false"> <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"> <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>
<div class="inline-flex gap-1 rounded bg-base p-px text-xs md:absolute md:-left-0.5 md:-top-2"> <div class="inline-flex gap-1 rounded bg-base p-px text-xs md:absolute md:-left-0.5 md:-top-2">
<div class="font-light uppercase">{{ label }}</div> <div class="font-light uppercase">{{ label }}</div>

View File

@@ -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>

View File

@@ -1,11 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = ` exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium"> "<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry"> <li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys=""> <div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if--> <!--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 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>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></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>" </ul>"
`; `;
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = ` exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium"> "<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry"> <li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys=""> <div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if--> <!--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 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>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></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>" </ul>"
`; `;
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = ` exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium"> "<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry"> <li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys=""> <div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if--> <!--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 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>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></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>" </ul>"
`; `;
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = ` exports[`<ContainerEventSource /> > render html correctly > should render messages with filter 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium"> "<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="2" class="group/entry"> <li data-v-cf9ff940="" data-key="2" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys=""> <div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if--> <!--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 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>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></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>" </ul>"
`; `;
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = ` exports[`<ContainerEventSource /> > render html correctly > should render messages with html entities 1`] = `
"<ul data-v-2e92daca="" class="events group py-4 medium"> "<ul data-v-cf9ff940="" class="events group py-4 medium">
<li data-v-2e92daca="" data-key="1" class="group/entry"> <li data-v-cf9ff940="" data-key="1" class="group/entry">
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys=""> <div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<!--v-if--> <!--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 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>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></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>" </ul>"
`; `;
exports[`<LogEventSource /> > renders correctly 1`] = ` exports[`<ContainerEventSource /> > renders correctly 1`] = `
"<div class="flex min-h-[1px] justify-center"><span class="loading loading-bars loading-md mt-4 text-primary" style="display: none;"></span></div> "<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>" <ul data-v-cf9ff940="" class="events group py-4 medium"></ul>"
`; `;
exports[`<LogEventSource /> > should parse messages 1`] = ` exports[`<ContainerEventSource /> > should parse messages 1`] = `
SimpleLogEntry { SimpleLogEntry {
"_message": "This is a message.", "_message": "This is a message.",
"containerID": undefined,
"date": 2019-06-12T10:55:42.459Z, "date": 2019-06-12T10:55:42.459Z,
"id": 1, "id": 1,
"level": undefined, "level": undefined,

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex flex-col gap-8 px-4 py-4 md:px-8"> <div class="flex flex-col gap-8 px-4 py-4 md:px-8">
<section> <section>
<links /> <Links />
</section> </section>
<slot></slot> <slot></slot>
</div> </div>

View File

@@ -7,9 +7,9 @@
{{ release.name }} {{ release.name }}
</a> </a>
<span class="ml-1 text-xs"><distance-time :date="new Date(release.createdAt)" /></span> <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") }} {{ $t("releases.latest") }}
</tag> </Tag>
</div> </div>
<div class="text-sm text-base-content/80"> <div class="text-sm text-base-content/80">
{{ summary(release) }} {{ summary(release) }}

View File

@@ -27,15 +27,17 @@ const { indeterminate = false, autoHide = false } = defineProps<{
const scrollProgress = ref(0); const scrollProgress = ref(0);
const root = ref<HTMLElement>(); 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 scrollElement = ref<HTMLElement | Document>((root.value?.closest("[data-scrolling]") as HTMLElement) ?? document);
const { y: scrollY } = useScroll(scrollElement as Ref<HTMLElement | Document>, { throttle: 100 }); const { y: scrollY } = useScroll(scrollElement as Ref<HTMLElement | Document>, { throttle: 100 });
const show = autoResetRef(false, 2000); const show = autoResetRef(false, 2000);
onMounted(() => { onMounted(() => {
watch( watch(
activeContainers, pinnedLogs,
() => { () => {
scrollElement.value = (root.value?.closest("[data-scrolling]") as HTMLElement) ?? document; scrollElement.value = (root.value?.closest("[data-scrolling]") as HTMLElement) ?? document;
}, },

View File

@@ -2,13 +2,13 @@
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col"> <section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
<header <header
v-if="$slots.header" v-if="$slots.header"
class="sticky top-[70px] z-[2] border-b border-base-content/10 bg-base py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0" class="sticky top-[70px] z-[2] h-14 border-b border-base-content/10 bg-base py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
> >
<slot name="header"></slot> <slot name="header"></slot>
</header> </header>
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto"> <main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
<div class="invisible mr-28 text-right md:visible" v-show="paused"> <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>
<div ref="scrollableContent"> <div ref="scrollableContent">
<slot :setLoading="setLoading"></slot> <slot :setLoading="setLoading"></slot>

View File

@@ -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>

View File

@@ -1,64 +1,17 @@
<template> <template>
<div v-if="ready" data-testid="side-menu"> <div v-if="ready" data-testid="side-menu">
<div class="breadcrumbs"> <Toggle v-model="showSwarm" v-if="services.length > 0 || customGroups.length > 0">
<ul> <div class="text-lg font-light">{{ $t("label.swarm-mode") }}</div>
<li><a @click.prevent="setHost(null)" class="link-primary">Hosts</a></li> </Toggle>
<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"> <SlideTransition :slide-right="showSwarm">
<ul class="menu p-0" v-if="!sessionHost"> <template #left>
<li v-for="host in hosts" :key="host.id"> <HostMenu />
<a @click.prevent="setHost(host.id)" :class="{ 'pointer-events-none text-base-content/50': !host.available }"> </template>
<ph:computer-tower /> <template #right>
{{ host.name }} <SwarmMenu />
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span> </template>
</a> </SlideTransition>
</li>
</ul>
<ul class="containers menu p-0 [&_li.menu-title]:px-0" v-else>
<li v-for="{ label, containers, icon } in menuItems" :key="label">
<!-- @vue-ignore see https://github.com/vuejs/core/pull/10938#pullrequestreview-2062827140 -->
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
<summary class="font-light text-base-content/80">
<component :is="icon" />
{{ label.startsWith("label.") ? $t(label) : label }}
</summary>
<ul>
<li v-for="item in containers" :class="item.state" :key="item.id">
<popup>
<router-link
:to="{ name: 'container-id', params: { id: item.id } }"
active-class="active-primary"
@click.alt.stop.prevent="store.appendActiveContainer(item)"
:title="item.name"
>
<div class="truncate">
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">{{ item.swarmId }}</span>
</div>
<container-health :health="item.health"></container-health>
<span
class="pin"
@click.stop.prevent="store.appendActiveContainer(item)"
v-show="!activeContainersById[item.id]"
:title="$t('tooltip.pin-column')"
>
<cil:columns />
</span>
</router-link>
<template #content>
<container-popup :container="item"></container-popup>
</template>
</popup>
</li>
</ul>
</details>
</li>
</ul>
</transition>
</div> </div>
<div role="status" class="flex animate-pulse flex-col gap-4" v-else> <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> <div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
@@ -67,164 +20,12 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Container } from "@/models/Container"; const containerStore = useContainerStore();
import { sessionHost } from "@/composable/storage"; const { ready } = storeToRefs(containerStore);
// @ts-ignore const swarmStore = useSwarmStore();
import Pin from "~icons/ph/map-pin-simple"; const { services, customGroups } = storeToRefs(swarmStore);
// @ts-ignore
import Stack from "~icons/ph/stack";
// @ts-ignore
import Containers from "~icons/octicon/container-24";
const store = useContainerStore(); const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
const { activeContainers, visibleContainers, ready } = storeToRefs(store);
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.labels["com.docker.stack.namespace"] ?? item.labels["com.docker.compose.project"];
if (debouncedPinnedContainers.value.has(item.name)) {
pinned.push(item);
} else if (namespace) {
namespaced[namespace] ||= [];
namespaced[namespace].push(item);
} else {
singular.push(item);
}
}
const items = [];
if (pinned.length) {
items.push({ label: "label.pinned", containers: pinned, icon: Pin });
}
for (const [label, containers] of Object.entries(namespaced).sort(([a], [b]) => a.localeCompare(b))) {
if (containers.length > 1) {
items.push({ label, containers, icon: Stack });
} else {
singular.push(containers[0]);
}
}
singular.sort(sorter);
if (singular.length) {
items.push({
label: showAllContainers.value ? "label.all-containers" : "label.running-containers",
containers: singular,
icon: Containers,
});
}
return items;
});
const activeContainersById = computed(() =>
activeContainers.value.reduce(
(acc, item) => {
acc[item.id] = item;
return acc;
},
{} as Record<string, Container>,
),
);
</script> </script>
<style scoped lang="postcss"> <style scoped lang="postcss"></style>
.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>

View File

@@ -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>

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>

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<template> <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> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -1,12 +1,12 @@
<template> <template>
<labeled-input> <LabeledInput>
<template #label> <template #label>
<slot /> <slot />
</template> </template>
<template #input> <template #input>
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" /> <input type="checkbox" class="toggle toggle-primary" v-model="modelValue" />
</template> </template>
</labeled-input> </LabeledInput>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View File

@@ -1,6 +1,7 @@
import { Container } from "@/models/Container";
type ContainerActions = "start" | "stop" | "restart"; type ContainerActions = "start" | "stop" | "restart";
export const useContainerActions = () => { export const useContainerActions = (container: Ref<Container>) => {
const { container } = useContainerContext();
const { showToast } = useToast(); const { showToast } = useToast();
const actionStates = reactive({ const actionStates = reactive({

View File

@@ -2,7 +2,6 @@ import { Container } from "@/models/Container";
type ContainerContext = { type ContainerContext = {
container: Ref<Container>; container: Ref<Container>;
streamConfig: { stdout: boolean; stderr: boolean };
}; };
export const containerContext = Symbol("containerContext") as InjectionKey<ContainerContext>; export const containerContext = Symbol("containerContext") as InjectionKey<ContainerContext>;
@@ -10,7 +9,6 @@ export const containerContext = Symbol("containerContext") as InjectionKey<Conta
export const provideContainerContext = (container: Ref<Container>) => { export const provideContainerContext = (container: Ref<Container>) => {
provide(containerContext, { provide(containerContext, {
container, container,
streamConfig: reactive({ stdout: true, stderr: true }),
}); });
}; };

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 };
}

View File

@@ -1,160 +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/hosts/${container.value.host}/containers/${containerId}/logs/stream?${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/hosts/${container.value.host}/containers/${containerId}/logs?${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 };
}

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;
};

View File

@@ -7,7 +7,7 @@ if (config.hosts.length === 1 && !sessionHost.value) {
sessionHost.value = config.hosts[0].id; 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", {}); const storage = useProfileStorage("visibleKeys", {});
return computed(() => { return computed(() => {
if (!(container.value.storageKey in storage.value)) { if (!(container.value.storageKey in storage.value)) {

View File

@@ -11,14 +11,14 @@
<router-view></router-view> <router-view></router-view>
</pane> </pane>
<template v-if="!isMobile"> <template v-if="!isMobile">
<pane v-for="other in activeContainers" :key="other.id"> <pane v-for="other in pinnedLogs" :key="other.id">
<log-container <ContainerLog
:id="other.id" :id="other.id"
show-title show-title
scrollable scrollable
closable closable
@close="containerStore.removeActiveContainer(other)" @close="pinnedLogsStore.unPinContainer(other)"
></log-container> />
</pane> </pane>
</template> </template>
</splitpanes> </splitpanes>
@@ -72,8 +72,8 @@
import { Splitpanes, Pane } from "splitpanes"; import { Splitpanes, Pane } from "splitpanes";
import { collapseNav } from "@/stores/settings"; import { collapseNav } from "@/stores/settings";
const containerStore = useContainerStore(); const pinnedLogsStore = usePinnedLogsStore();
const { activeContainers } = storeToRefs(containerStore); const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const { toasts, removeToast } = useToast(); const { toasts, removeToast } = useToast();

View File

@@ -18,7 +18,20 @@ describe("Container", () => {
]; ];
test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => { test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => {
const c = new Container("id", new Date(), "image", name!, "command", "host", {}, "status", "created", []); const c = new Container(
"id",
new Date(),
"image",
name!,
"command",
"host",
{},
"status",
"created",
[],
"group",
"healthy",
);
expect(c.name).toBe(expectedName); expect(c.name).toBe(expectedName);
expect(c.swarmId).toBe(expectedSwarmId); expect(c.swarmId).toBe(expectedSwarmId);
}); });

View File

@@ -2,7 +2,7 @@ import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Con
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils"; import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
import { Ref } from "vue"; import { Ref } from "vue";
type Stat = Omit<ContainerStat, "id">; export type Stat = Omit<ContainerStat, "id">;
const SWARM_ID_REGEX = /(\.[a-z0-9]{25})+$/i; const SWARM_ID_REGEX = /(\.[a-z0-9]{25})+$/i;
@@ -16,6 +16,13 @@ const hosts = computed(() =>
), ),
); );
export class GroupedContainers {
constructor(
public readonly name: string,
public readonly containers: Container[],
) {}
}
export class Container { export class Container {
private _stat: Ref<Stat>; private _stat: Ref<Stat>;
private readonly _statsHistory: Ref<Stat[]>; private readonly _statsHistory: Ref<Stat[]>;
@@ -34,6 +41,7 @@ export class Container {
public status: string, public status: string,
public state: ContainerState, public state: ContainerState,
stats: Stat[], stats: Stat[],
public readonly group?: string,
public health?: ContainerHealth, public health?: ContainerHealth,
) { ) {
this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat)); this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat));
@@ -68,6 +76,14 @@ export class Container {
return `${stripVersion(this.image)}:${this.command}`; 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;
}
public updateStat(stat: Stat) { public updateStat(stat: Stat) {
if (isRef(this._stat)) { if (isRef(this._stat)) {
this._stat.value = stat; this._stat.value = stat;

View File

@@ -17,12 +17,14 @@ export interface LogEvent {
readonly l: Level; readonly l: Level;
readonly p: Position; readonly p: Position;
readonly s: "stdout" | "stderr" | "unknown"; readonly s: "stdout" | "stderr" | "unknown";
readonly c: string;
} }
export abstract class LogEntry<T extends string | JSONObject> { export abstract class LogEntry<T extends string | JSONObject> {
protected readonly _message: T; protected readonly _message: T;
constructor( constructor(
message: T, message: T,
public readonly containerID: string,
public readonly id: number, public readonly id: number,
public readonly date: Date, public readonly date: Date,
public readonly std: Std, public readonly std: Std,
@@ -41,13 +43,14 @@ export abstract class LogEntry<T extends string | JSONObject> {
export class SimpleLogEntry extends LogEntry<string> { export class SimpleLogEntry extends LogEntry<string> {
constructor( constructor(
message: string, message: string,
containerID: string,
id: number, id: number,
date: Date, date: Date,
public readonly level: Level, public readonly level: Level,
public readonly position: Position, public readonly position: Position,
public readonly std: Std, public readonly std: Std,
) { ) {
super(message, id, date, std, level); super(message, containerID, id, date, std, level);
} }
getComponent(): Component { getComponent(): Component {
return SimpleLogItem; return SimpleLogItem;
@@ -59,13 +62,14 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
constructor( constructor(
message: JSONObject, message: JSONObject,
containerID: string,
id: number, id: number,
date: Date, date: Date,
public readonly level: Level, public readonly level: Level,
public readonly std: Std, public readonly std: Std,
visibleKeys?: Ref<string[][]>, visibleKeys?: Ref<string[][]>,
) { ) {
super(message, id, date, std, level); super(message, containerID, id, date, std, level);
if (visibleKeys) { if (visibleKeys) {
this.filteredMessage = computed(() => { this.filteredMessage = computed(() => {
if (!visibleKeys.value.length) { if (!visibleKeys.value.length) {
@@ -91,17 +95,26 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
} }
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry { 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> { export class DockerEventLogEntry extends LogEntry<string> {
constructor( constructor(
message: string, message: string,
containerID: string,
date: Date, date: Date,
public readonly event: "container-stopped" | "container-started", public readonly event: "container-stopped" | "container-started",
) { ) {
super(message, date.getTime(), date, "stderr", "info"); super(message, containerID, date.getTime(), date, "stderr", "info");
} }
getComponent(): Component { getComponent(): Component {
return DockerEventLogItem; return DockerEventLogItem;
@@ -118,7 +131,7 @@ export class SkippedLogsEntry extends LogEntry<string> {
public readonly firstSkipped: LogEntry<string | JSONObject>, public readonly firstSkipped: LogEntry<string | JSONObject>,
lastSkipped: LogEntry<string | JSONObject>, lastSkipped: LogEntry<string | JSONObject>,
) { ) {
super("", date.getTime(), date, "stderr", "info"); super("", "", date.getTime(), date, "stderr", "info");
this._totalSkipped = totalSkipped; this._totalSkipped = totalSkipped;
this.lastSkipped = lastSkipped; this.lastSkipped = lastSkipped;
} }
@@ -148,6 +161,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
if (isObject(event.m)) { if (isObject(event.m)) {
return new ComplexLogEntry( return new ComplexLogEntry(
event.m, event.m,
event.c,
event.id, event.id,
new Date(event.ts), new Date(event.ts),
event.l, event.l,
@@ -156,6 +170,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
} else { } else {
return new SimpleLogEntry( return new SimpleLogEntry(
event.m, event.m,
event.c,
event.id, event.id,
new Date(event.ts), new Date(event.ts),
event.l, event.l,

22
assets/models/Stack.ts Normal file
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;
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<page-with-links> <PageWithLinks>
<div class="hero min-h-screen bg-base-200"> <div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center"> <div class="hero-content text-center">
<div class="max-w-md"> <div class="max-w-md">
@@ -7,7 +7,7 @@
</div> </div>
</div> </div>
</div> </div>
</page-with-links> </PageWithLinks>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View File

@@ -1,11 +1,6 @@
<template> <template>
<search></search> <Search />
<log-container <ContainerLog :id="id" :show-title="true" :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
:id="id"
:show-title="true"
:scrollable="activeContainers.length > 0"
v-if="currentContainer"
></log-container>
<div v-else-if="ready" class="hero min-h-screen bg-base-200"> <div v-else-if="ready" class="hero min-h-screen bg-base-200">
<div class="hero-content text-center"> <div class="hero-content text-center">
<div class="max-w-md"> <div class="max-w-md">
@@ -16,12 +11,14 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import search from "@/components/Search.vue";
const store = useContainerStore();
const { id } = defineProps<{ id: string }>(); const { id } = defineProps<{ id: string }>();
const currentContainer = store.currentContainer($$(id)); const containerStore = useContainerStore();
const { activeContainers, ready } = storeToRefs(store); const currentContainer = containerStore.currentContainer($$(id));
const { ready } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
watchEffect(() => { watchEffect(() => {
if (ready.value) { if (ready.value) {

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>

View File

@@ -1,13 +1,13 @@
<template> <template>
<page-with-links> <PageWithLinks>
<section> <section>
<host-list /> <HostList />
</section> </section>
<section> <section>
<container-table :containers="runningContainers"></container-table> <ContainerTable :containers="runningContainers"></ContainerTable>
</section> </section>
</page-with-links> </PageWithLinks>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

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 = route.query.id as string[];
watchEffect(() => {
if (ready.value) {
setTitle("Merged Logs");
}
});
</script>

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>

View File

@@ -1,5 +1,5 @@
<template> <template>
<page-with-links> <PageWithLinks>
<section> <section>
<div class="has-underline"> <div class="has-underline">
<h2>{{ $t("settings.about") }}</h2> <h2>{{ $t("settings.about") }}</h2>
@@ -21,17 +21,17 @@
<section class="grid-cols-2 gap-4 @3xl:grid"> <section class="grid-cols-2 gap-4 @3xl:grid">
<div class="flex flex-col gap-2 text-balance @3xl:pr-8"> <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> <template #label>
{{ $t("settings.locale") }} {{ $t("settings.locale") }}
</template> </template>
@@ -44,9 +44,9 @@
]" ]"
/> />
</template> </template>
</labeled-input> </LabeledInput>
<labeled-input> <LabeledInput>
<template #label> <template #label>
{{ $t("settings.datetime-format") }} {{ $t("settings.datetime-format") }}
</template> </template>
@@ -72,9 +72,9 @@
/> />
</div> </div>
</template> </template>
</labeled-input> </LabeledInput>
<labeled-input> <LabeledInput>
<template #label> <template #label>
{{ $t("settings.font-size") }} {{ $t("settings.font-size") }}
</template> </template>
@@ -88,9 +88,9 @@
]" ]"
/> />
</template> </template>
</labeled-input> </LabeledInput>
<labeled-input> <LabeledInput>
<template #label> <template #label>
{{ $t("settings.color-scheme") }} {{ $t("settings.color-scheme") }}
</template> </template>
@@ -104,12 +104,13 @@
]" ]"
/> />
</template> </template>
</labeled-input> </LabeledInput>
</div> </div>
<log-viewer <LogList
:messages="fakeMessages" :messages="fakeMessages"
:visible-keys="keys" :visible-keys="keys"
:last-selected-item="undefined" :last-selected-item="undefined"
:show-container-name="false"
class="hidden overflow-hidden rounded-lg border border-base-content/50 shadow @3xl:block" class="hidden overflow-hidden rounded-lg border border-base-content/50 shadow @3xl:block"
/> />
</section> </section>
@@ -133,7 +134,7 @@
<toggle v-model="automaticRedirect">{{ $t("settings.automatic-redirect") }}</toggle> <toggle v-model="automaticRedirect">{{ $t("settings.automatic-redirect") }}</toggle>
</div> </div>
</section> </section>
</page-with-links> </PageWithLinks>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -168,11 +169,11 @@ const hoursAgo = (hours: number) => {
}; };
const fakeMessages = [ const fakeMessages = [
new SimpleLogEntry("This is a preview of the logs", 1, hoursAgo(16), "info", undefined, "stdout"), new SimpleLogEntry("This is a preview of the logs", "123", 1, hoursAgo(16), "info", undefined, "stdout"),
new SimpleLogEntry("A warning log looks like this", 2, hoursAgo(12), "warn", 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", 3, hoursAgo(7), "error", "start", "stderr"), new SimpleLogEntry("This is a multi line error message", "123", 3, hoursAgo(7), "error", "start", "stderr"),
new SimpleLogEntry("with a second line", 4, hoursAgo(2), "error", "middle", "stderr"), new SimpleLogEntry("with a second line", "123", 4, hoursAgo(2), "error", "middle", "stderr"),
new SimpleLogEntry("and finally third line.", 5, new Date(), "error", "end", "stderr"), new SimpleLogEntry("and finally third line.", "123", 5, new Date(), "error", "end", "stderr"),
new ComplexLogEntry( new ComplexLogEntry(
{ {
message: "This is a complex log entry as json", message: "This is a complex log entry as json",
@@ -181,6 +182,7 @@ const fakeMessages = [
key2: "value2", key2: "value2",
}, },
}, },
"123",
6, 6,
new Date(), new Date(),
"info", "info",
@@ -189,6 +191,7 @@ const fakeMessages = [
), ),
new SimpleLogEntry( 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. ", "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, 7,
new Date(), new Date(),
"debug", "debug",

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>

View File

@@ -11,7 +11,7 @@ const { t } = i18n.global;
export const useContainerStore = defineStore("container", () => { export const useContainerStore = defineStore("container", () => {
const containers: Ref<Container[]> = ref([]); const containers: Ref<Container[]> = ref([]);
const activeContainerIds: Ref<string[]> = ref([]);
let es: EventSource | null = null; let es: EventSource | null = null;
const ready = ref(false); const ready = ref(false);
@@ -30,8 +30,6 @@ export const useContainerStore = defineStore("container", () => {
return containers.value.filter(filter); return containers.value.filter(filter);
}); });
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
function connect() { function connect() {
es?.close(); es?.close();
ready.value = false; ready.value = false;
@@ -135,6 +133,7 @@ export const useContainerStore = defineStore("container", () => {
c.status, c.status,
c.state, c.state,
c.stats, c.stats,
c.group,
c.health, c.health,
); );
}), }),
@@ -142,19 +141,23 @@ export const useContainerStore = defineStore("container", () => {
}; };
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]); const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
const appendActiveContainer = ({ id }: { id: string }) => activeContainerIds.value.push(id);
const removeActiveContainer = ({ id }: { id: string }) => const containerNames = computed(() =>
activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1); containers.value.reduce(
(acc, container) => {
acc[container.id] = container.name;
return acc;
},
{} as Record<string, string>,
),
);
return { return {
containers, containers,
activeContainerIds,
allContainersById, allContainersById,
visibleContainers, visibleContainers,
activeContainers,
currentContainer, currentContainer,
appendActiveContainer, containerNames,
removeActiveContainer,
ready, ready,
}; };
}); });

27
assets/stores/pinned.ts Normal file
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
assets/stores/swarm.ts Normal file
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));
}

View File

@@ -17,6 +17,7 @@ export type ContainerJson = {
readonly labels: Record<string, string>; readonly labels: Record<string, string>;
readonly stats: ContainerStat[]; readonly stats: ContainerStat[];
readonly health?: ContainerHealth; readonly health?: ContainerHealth;
readonly group?: string;
}; };
export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting"; export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting";

View File

@@ -75,3 +75,12 @@ export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHist
return history; 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;
}

View File

@@ -1,4 +1,3 @@
version: "3.4"
services: services:
custom_base: custom_base:
container_name: custom_base container_name: custom_base

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -213,6 +213,12 @@ func (d *httpClient) ListContainers() ([]Container, error) {
if len(c.Names) > 0 { if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/") name = strings.TrimPrefix(c.Names[0], "/")
} }
group := ""
if c.Labels["dev.dozzle.group"] != "" {
group = c.Labels["dev.dozzle.group"]
}
container := Container{ container := Container{
ID: c.ID[:12], ID: c.ID[:12],
Names: c.Names, Names: c.Names,
@@ -227,6 +233,7 @@ func (d *httpClient) ListContainers() ([]Container, error) {
Health: findBetweenParentheses(c.Status), Health: findBetweenParentheses(c.Status),
Labels: c.Labels, Labels: c.Labels,
Stats: utils.NewRingBuffer[ContainerStat](300), // 300 seconds of stats Stats: utils.NewRingBuffer[ContainerStat](300), // 300 seconds of stats
Group: group,
} }
containers = append(containers, container) containers = append(containers, container)
} }
@@ -303,7 +310,7 @@ func (d *httpClient) ContainerLogs(ctx context.Context, id string, since string,
ShowStdout: stdType&STDOUT != 0, ShowStdout: stdType&STDOUT != 0,
ShowStderr: stdType&STDERR != 0, ShowStderr: stdType&STDERR != 0,
Follow: true, Follow: true,
Tail: "300", Tail: strconv.Itoa(100),
Timestamps: true, Timestamps: true,
Since: since, Since: since,
} }

View File

@@ -149,7 +149,7 @@ func Test_dockerClient_ContainerLogs_happy(t *testing.T) {
b = append(b, []byte(expected)...) b = append(b, []byte(expected)...)
reader := io.NopCloser(bytes.NewReader(b)) 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) proxy.On("ContainerLogs", mock.Anything, id, options).Return(reader, nil)
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}} client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}

View File

@@ -11,25 +11,27 @@ import (
) )
type ContainerStore struct { type ContainerStore struct {
containers *xsync.MapOf[string, *Container] containers *xsync.MapOf[string, *Container]
subscribers *xsync.MapOf[context.Context, chan ContainerEvent] subscribers *xsync.MapOf[context.Context, chan ContainerEvent]
client Client newContainerSubscribers *xsync.MapOf[context.Context, chan Container]
statsCollector *StatsCollector client Client
wg sync.WaitGroup statsCollector *StatsCollector
connected atomic.Bool wg sync.WaitGroup
events chan ContainerEvent connected atomic.Bool
ctx context.Context events chan ContainerEvent
ctx context.Context
} }
func NewContainerStore(ctx context.Context, client Client) *ContainerStore { func NewContainerStore(ctx context.Context, client Client) *ContainerStore {
s := &ContainerStore{ s := &ContainerStore{
containers: xsync.NewMapOf[string, *Container](), containers: xsync.NewMapOf[string, *Container](),
client: client, client: client,
subscribers: xsync.NewMapOf[context.Context, chan ContainerEvent](), subscribers: xsync.NewMapOf[context.Context, chan ContainerEvent](),
statsCollector: NewStatsCollector(client), newContainerSubscribers: xsync.NewMapOf[context.Context, chan Container](),
wg: sync.WaitGroup{}, statsCollector: NewStatsCollector(client),
events: make(chan ContainerEvent), wg: sync.WaitGroup{},
ctx: ctx, events: make(chan ContainerEvent),
ctx: ctx,
} }
s.wg.Add(1) s.wg.Add(1)
@@ -105,6 +107,10 @@ func (s *ContainerStore) SubscribeStats(ctx context.Context, stats chan Containe
s.statsCollector.Subscribe(ctx, stats) s.statsCollector.Subscribe(ctx, stats)
} }
func (s *ContainerStore) SubscribeNewContainers(ctx context.Context, containers chan Container) {
s.newContainerSubscribers.Store(ctx, containers)
}
func (s *ContainerStore) init() { func (s *ContainerStore) init() {
stats := make(chan ContainerStat) stats := make(chan ContainerStat)
s.statsCollector.Subscribe(s.ctx, stats) s.statsCollector.Subscribe(s.ctx, stats)
@@ -122,6 +128,14 @@ func (s *ContainerStore) init() {
if container, err := s.client.FindContainer(event.ActorID); err == nil { if container, err := s.client.FindContainer(event.ActorID); err == nil {
log.Debugf("container %s started", container.ID) log.Debugf("container %s started", container.ID)
s.containers.Store(container.ID, &container) 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": case "destroy":
log.Debugf("container %s destroyed", event.ActorID) log.Debugf("container %s destroyed", event.ActorID)

View File

@@ -22,13 +22,14 @@ import (
) )
type EventGenerator struct { type EventGenerator struct {
Events chan *LogEvent Events chan *LogEvent
Errors chan error Errors chan error
reader *bufio.Reader reader *bufio.Reader
next *LogEvent next *LogEvent
buffer chan *LogEvent buffer chan *LogEvent
tty bool tty bool
wg sync.WaitGroup wg sync.WaitGroup
containerID string
} }
var bufPool = sync.Pool{ var bufPool = sync.Pool{
@@ -39,13 +40,14 @@ var bufPool = sync.Pool{
var ErrBadHeader = fmt.Errorf("dozzle/docker: unable to read header") 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{ generator := &EventGenerator{
reader: bufio.NewReader(reader), reader: bufio.NewReader(reader),
buffer: make(chan *LogEvent, 100), buffer: make(chan *LogEvent, 100),
Errors: make(chan error, 1), Errors: make(chan error, 1),
Events: make(chan *LogEvent), Events: make(chan *LogEvent),
tty: tty, tty: container.Tty,
containerID: container.ID,
} }
generator.wg.Add(2) generator.wg.Add(2)
go generator.consumeReader() go generator.consumeReader()
@@ -84,7 +86,7 @@ func (g *EventGenerator) consumeReader() {
message, streamType, readerError := readEvent(g.reader, g.tty) message, streamType, readerError := readEvent(g.reader, g.tty)
if message != "" { if message != "" {
logEvent := createEvent(message, streamType) logEvent := createEvent(message, streamType)
logEvent.ContainerID = g.containerID
logEvent.Level = guessLogLevel(logEvent) logEvent.Level = guessLogLevel(logEvent)
g.buffer <- logEvent g.buffer <- logEvent
} }

View File

@@ -19,7 +19,7 @@ func TestEventGenerator_Events_tty(t *testing.T) {
input := "example input" input := "example input"
reader := bufio.NewReader(strings.NewReader(input)) reader := bufio.NewReader(strings.NewReader(input))
g := NewEventGenerator(reader, true) g := NewEventGenerator(reader, Container{Tty: true})
event := <-g.Events event := <-g.Events
require.NotNil(t, event, "Expected event to not be nil, but got nil") 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" input := "example input"
reader := bytes.NewReader(makeMessage(input, STDOUT)) reader := bytes.NewReader(makeMessage(input, STDOUT))
g := NewEventGenerator(reader, false) g := NewEventGenerator(reader, Container{Tty: false})
event := <-g.Events event := <-g.Events
require.NotNil(t, event, "Expected event to not be nil, but got nil") 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" input := "example input"
reader := bytes.NewReader(makeMessage(input, STDOUT)) reader := bytes.NewReader(makeMessage(input, STDOUT))
g := NewEventGenerator(reader, false) g := NewEventGenerator(reader, Container{Tty: false})
<-g.Events <-g.Events
_, ok := <-g.Events _, ok := <-g.Events
@@ -52,7 +52,7 @@ func TestEventGenerator_Events_routines_done(t *testing.T) {
input := "example input" input := "example input"
reader := bytes.NewReader(makeMessage(input, STDOUT)) reader := bytes.NewReader(makeMessage(input, STDOUT))
g := NewEventGenerator(reader, false) g := NewEventGenerator(reader, Container{Tty: false})
<-g.Events <-g.Events
assert.False(t, waitTimeout(&g.wg, 1*time.Second), "Expected routines to be done") assert.False(t, waitTimeout(&g.wg, 1*time.Second), "Expected routines to be done")
} }

View File

@@ -22,6 +22,7 @@ type Container struct {
Tty bool `json:"-"` Tty bool `json:"-"`
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty"`
Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"` Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
Group string `json:"group,omitempty"`
} }
// ContainerStat represent stats instant for a container // ContainerStat represent stats instant for a container
@@ -48,12 +49,13 @@ const (
) )
type LogEvent struct { type LogEvent struct {
Message any `json:"m,omitempty"` Message any `json:"m,omitempty"`
Timestamp int64 `json:"ts"` Timestamp int64 `json:"ts"`
Id uint32 `json:"id,omitempty"` Id uint32 `json:"id,omitempty"`
Level string `json:"l,omitempty"` Level string `json:"l,omitempty"`
Position LogPosition `json:"p,omitempty"` Position LogPosition `json:"p,omitempty"`
Stream string `json:"s,omitempty"` Stream string `json:"s,omitempty"`
ContainerID string `json:"c,omitempty"`
} }
func (l *LogEvent) HasLevel() bool { func (l *LogEvent) HasLevel() bool {

View File

@@ -24,12 +24,12 @@ Location: /foobar/
<a href="/foobar/">Moved Permanently</a>. <a href="/foobar/">Moved Permanently</a>.
/* snapshot: Test_createRoutes_redirect_with_auth */ /* snapshot: Test_createRoutes_redirect_with_auth */
HTTP/1.1 307 Temporary Redirect HTTP/1.1 307 Temporary Redirect
Connection: close Connection: close
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/html; charset=utf-8 Content-Type: text/html; charset=utf-8
Location: /foobar/login Location: /foobar/login
<a href="/foobar/login">Temporary Redirect</a>. <a href="/foobar/login">Temporary Redirect</a>.
/* snapshot: Test_createRoutes_simple_redirect */ /* snapshot: Test_createRoutes_simple_redirect */
@@ -42,33 +42,33 @@ Location: /login?redirectUrl=/
<a href="/login?redirectUrl=/">Temporary Redirect</a>. <a href="/login?redirectUrl=/">Temporary Redirect</a>.
/* snapshot: Test_createRoutes_username_password */ /* snapshot: Test_createRoutes_username_password */
HTTP/1.1 307 Temporary Redirect HTTP/1.1 307 Temporary Redirect
Connection: close Connection: close
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/html; charset=utf-8 Content-Type: text/html; charset=utf-8
Location: /login Location: /login
<a href="/login">Temporary Redirect</a>. <a href="/login">Temporary Redirect</a>.
/* snapshot: Test_createRoutes_username_password_invalid */ /* snapshot: Test_createRoutes_username_password_invalid */
HTTP/1.1 401 Unauthorized HTTP/1.1 401 Unauthorized
Connection: close Connection: close
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
Unauthorized Unauthorized
/* snapshot: Test_createRoutes_username_password_valid_session */ /* snapshot: Test_createRoutes_username_password_valid_session */
HTTP/1.1 200 OK HTTP/1.1 200 OK
Connection: close Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
event: container-stopped event: container-stopped
data: end of stream 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-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: application/x-jsonl; charset=UTF-8 Content-Type: application/x-jsonl; charset=UTF-8
{"m":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout"} {"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"} {"m":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
/* snapshot: Test_handler_download_logs */ /* snapshot: Test_handler_download_logs */
INFO Testing logs... INFO Testing logs...
@@ -105,15 +105,15 @@ event: containers-changed
data: [] data: []
/* snapshot: Test_handler_streamEvents_error_request */ /* snapshot: Test_handler_streamEvents_error_request */
HTTP/1.1 200 OK HTTP/1.1 200 OK
Connection: close Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
event: containers-changed event: containers-changed
data: [] data: []
@@ -148,17 +148,14 @@ X-Content-Type-Options: nosniff
error finding container error finding container
/* snapshot: Test_handler_streamLogs_error_reading */ /* snapshot: Test_handler_streamLogs_error_reading */
HTTP/1.1 500 Internal Server Error HTTP/1.1 200 OK
Connection: close Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/plain; charset=utf-8 Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
X-Content-Type-Options: nosniff
test error
/* snapshot: Test_handler_streamLogs_error_std */ /* snapshot: Test_handler_streamLogs_error_std */
HTTP/1.1 400 Bad Request 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 Content-Type: text/event-stream
X-Accel-Buffering: no 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 event: container-stopped
data: end of stream data: {"actorId":"123456","name":"container-stopped","host":"localhost"}
/* snapshot: Test_handler_streamLogs_happy_container_stopped */ /* snapshot: Test_handler_streamLogs_happy_container_stopped */
HTTP/1.1 200 OK HTTP/1.1 200 OK
@@ -192,10 +189,7 @@ Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
event: container-stopped
data: end of stream
/* snapshot: Test_handler_streamLogs_happy_with_id */ /* snapshot: Test_handler_streamLogs_happy_with_id */
HTTP/1.1 200 OK 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 Content-Type: text/event-stream
X-Accel-Buffering: no 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 id: 1589396137772
event: container-stopped event: container-stopped
data: end of stream data: {"actorId":"123456","name":"container-stopped","host":"localhost"}

View File

@@ -43,7 +43,6 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
} }
store.SubscribeStats(ctx, stats) store.SubscribeStats(ctx, stats)
store.Subscribe(ctx, events) store.Subscribe(ctx, events)
} }
defer func() { defer func() {

View File

@@ -107,7 +107,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
return return
} }
g := docker.NewEventGenerator(reader, container.Tty) g := docker.NewEventGenerator(reader, container)
encoder := json.NewEncoder(w) encoder := json.NewEncoder(w)
for event := range g.Events { 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) { func (h *handler) newContainers(ctx context.Context) chan docker.Container {
id := chi.URLParam(r, "id") 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 var stdTypes docker.StdType
if r.URL.Query().Has("stdout") { if r.URL.Query().Has("stdout") {
stdTypes |= docker.STDOUT stdTypes |= docker.STDOUT
@@ -139,47 +336,22 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
return 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("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-transform") w.Header().Set("Cache-Control", "no-transform")
w.Header().Add("Cache-Control", "no-cache") w.Header().Add("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
lastEventId := r.Header.Get("Last-Event-ID") logs := make(chan *docker.LogEvent)
if len(r.URL.Query().Get("lastEventId")) > 0 { events := make(chan *docker.ContainerEvent)
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
}
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop() defer ticker.Stop()
g := docker.NewEventGenerator(reader, container.Tty)
loop: loop:
for { for {
select { select {
case event, ok := <-g.Events: case event := <-logs:
if !ok {
log.WithFields(log.Fields{"id": id}).Debug("stream closed")
break loop
}
if buf, err := json.Marshal(event); err != nil { if buf, err := json.Marshal(event); err != nil {
log.Errorf("json encoding error while streaming %v", err.Error()) log.Errorf("json encoding error while streaming %v", err.Error())
} else { } else {
@@ -193,27 +365,49 @@ loop:
case <-ticker.C: case <-ticker.C:
fmt.Fprintf(w, ":ping \n\n") fmt.Fprintf(w, ":ping \n\n")
f.Flush() 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 event := <-events:
case err := <-g.Errors: log.Debugf("received container event %v", event)
if err != nil { if buf, err := json.Marshal(event); err != nil {
if err == io.EOF { log.Errorf("json encoding error while streaming %v", err.Error())
log.Debugf("container stopped: %v", container.ID) } else {
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") fmt.Fprintf(w, "event: container-stopped\ndata: %s\n\n", buf)
f.Flush() 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) { if log.IsLevelEnabled(log.DebugLevel) {
var m runtime.MemStats var m runtime.MemStats
runtime.ReadMemStats(&m) runtime.ReadMemStats(&m)
// For info on each, see: https://golang.org/pkg/runtime/#MemStats
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"allocated": humanize.Bytes(m.Alloc), "allocated": humanize.Bytes(m.Alloc),
"totalAllocated": humanize.Bytes(m.TotalAlloc), "totalAllocated": humanize.Bytes(m.TotalAlloc),

View File

@@ -2,6 +2,7 @@ package web
import ( import (
"bytes" "bytes"
"context"
"encoding/binary" "encoding/binary"
"errors" "errors"
"io" "io"
@@ -19,8 +20,11 @@ import (
) )
func Test_handler_streamLogs_happy(t *testing.T) { func Test_handler_streamLogs_happy(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
id := "123456" 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 := req.URL.Query()
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
@@ -32,8 +36,14 @@ func Test_handler_streamLogs_happy(t *testing.T) {
data := makeMessage("INFO Testing logs...", docker.STDOUT) data := makeMessage("INFO Testing logs...", docker.STDOUT)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Tty: false}, 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) 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) handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder() 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) { func Test_handler_streamLogs_happy_with_id(t *testing.T) {
id := "123456" 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 := req.URL.Query()
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "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) 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("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) 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) handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder() 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) { func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
id := "123456" 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 := req.URL.Query()
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "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.") require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) 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) 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) handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder() 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) { func Test_handler_streamLogs_error_finding_container(t *testing.T) {
id := "123456" 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 := req.URL.Query()
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "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.") require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient) 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) handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder() 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) { func Test_handler_streamLogs_error_reading(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
id := "123456" 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 := req.URL.Query()
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "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.") require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) 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")) 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) handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder() 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.") require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil)
handler := createDefaultHandler(mockedClient) handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()

View File

@@ -90,9 +90,13 @@ func createRouter(h *handler) *chi.Mux {
if h.config.Authorization.Provider != NONE { if h.config.Authorization.Provider != NONE {
r.Use(auth.RequireAuthentication) 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/download", h.downloadLogs)
r.Get("/api/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates) 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) r.Get("/api/events/stream", h.streamEvents)
if h.config.EnableActions { if h.config.EnableActions {
r.Post("/api/hosts/{host}/containers/{id}/actions/{action}", h.containerActions) r.Post("/api/hosts/{host}/containers/{id}/actions/{action}", h.containerActions)

View File

@@ -10,6 +10,7 @@ toolbar:
label: label:
containers: Containers containers: Containers
container: No containers | 1 container | {count} containers container: No containers | 1 container | {count} containers
serivce: No services | 1 service | {count} services
running-containers: Running Containers running-containers: Running Containers
all-containers: All Containers all-containers: All Containers
host: Host host: Host
@@ -22,7 +23,9 @@ label:
avg-mem: Avg. MEM (%) avg-mem: Avg. MEM (%)
pinned: Pinned pinned: Pinned
per-page: Rows per page per-page: Rows per page
swarm-mode: Swam Mode
services: Services
custom-groups: Custom Groups
tooltip: tooltip:
search: Search containers (⌘ + k, ⌃k) search: Search containers (⌘ + k, ⌃k)
pin-column: Pin as column pin-column: Pin as column

View File

@@ -100,6 +100,7 @@
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitepress": "1.2.2", "vitepress": "1.2.2",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vue-component-type-helpers": "^2.0.19",
"vue-tsc": "^2.0.19" "vue-tsc": "^2.0.19"
}, },
"lint-staged": { "lint-staged": {

11
pnpm-lock.yaml generated
View File

@@ -219,6 +219,9 @@ importers:
vitest: vitest:
specifier: ^1.6.0 specifier: ^1.6.0
version: 1.6.0(@types/node@20.12.12)(jsdom@24.0.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: vue-tsc:
specifier: ^2.0.19 specifier: ^2.0.19
version: 2.0.19(typescript@5.4.5) version: 2.0.19(typescript@5.4.5)
@@ -3293,8 +3296,8 @@ packages:
jsdom: jsdom:
optional: true optional: true
vue-component-type-helpers@2.0.11: vue-component-type-helpers@2.0.19:
resolution: {integrity: sha512-8aluKz5oVC8PvVQAYgyIefOlqzKVmAOTCx2imbrFBVLbF7mnJvyMsE2A7rqX/4f4uT6ee9o8u3GcoRpUWc0xsw==} resolution: {integrity: sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==}
vue-demi@0.14.7: vue-demi@0.14.7:
resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
@@ -4546,7 +4549,7 @@ snapshots:
'@vue/test-utils@2.4.6': '@vue/test-utils@2.4.6':
dependencies: dependencies:
js-beautify: 1.15.1 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))': '@vueuse/components@10.9.0(vue@3.4.27(typescript@5.4.5))':
dependencies: dependencies:
@@ -6693,7 +6696,7 @@ snapshots:
- supports-color - supports-color
- terser - 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)): vue-demi@0.14.7(vue@3.4.27(typescript@5.4.5)):
dependencies: dependencies: