Compare commits
	
		
			111 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					b1bc706de2 | ||
| 
						 | 
					40f5cb1301 | ||
| 
						 | 
					cedfbee983 | ||
| 
						 | 
					c835f51cc4 | ||
| 
						 | 
					5ab06d5906 | ||
| 
						 | 
					d44316fa9c | ||
| 
						 | 
					6ef3da9abd | ||
| 
						 | 
					752495ed6f | ||
| 
						 | 
					8f895e40bc | ||
| 
						 | 
					cd9ddcf427 | ||
| 
						 | 
					bbc7794006 | ||
| 
						 | 
					7dc37f130c | ||
| 
						 | 
					0711bc1c76 | ||
| 
						 | 
					0aa24386b2 | ||
| 
						 | 
					ca35b93671 | ||
| 
						 | 
					a6220e4d38 | ||
| 
						 | 
					4ed64a7cce | ||
| 
						 | 
					0f27e11084 | ||
| 
						 | 
					85eafc9c40 | ||
| 
						 | 
					332cc384ea | ||
| 
						 | 
					72fd31f85b | ||
| 
						 | 
					a0ce370e9e | ||
| 
						 | 
					e823904865 | ||
| 
						 | 
					22bbfe1592 | ||
| 
						 | 
					770e1818f0 | ||
| 
						 | 
					d6fab75f8f | ||
| 
						 | 
					17c18c156e | ||
| 
						 | 
					5eca19840e | ||
| 
						 | 
					b1d7b8ba55 | ||
| 
						 | 
					e2ee430bbd | ||
| 
						 | 
					0755a71dc2 | ||
| 
						 | 
					60758db9c8 | ||
| 
						 | 
					7b96196904 | ||
| 
						 | 
					efcfa0e375 | ||
| 
						 | 
					66f9204ae6 | ||
| 
						 | 
					73c023ce22 | ||
| 
						 | 
					261517ac3f | ||
| 
						 | 
					2e0a546aa2 | ||
| 
						 | 
					72ed7b50ba | ||
| 
						 | 
					486bcec363 | ||
| 
						 | 
					3db0ad42fe | ||
| 
						 | 
					c1a75e21ba | ||
| 
						 | 
					96c5e24501 | ||
| 
						 | 
					c1a16fd76e | ||
| 
						 | 
					42fab58c9f | ||
| 
						 | 
					400cef767f | ||
| 
						 | 
					84ae558467 | ||
| 
						 | 
					0ebc9c562a | ||
| 
						 | 
					f67664470f | ||
| 
						 | 
					1f811da273 | ||
| 
						 | 
					fdfc9fceba | ||
| 
						 | 
					5b5b741b68 | ||
| 
						 | 
					18c88d0e85 | ||
| 
						 | 
					1603a19538 | ||
| 
						 | 
					5cffa287d5 | ||
| 
						 | 
					93f57b6e90 | ||
| 
						 | 
					2346f6a0eb | ||
| 
						 | 
					f95317ac1d | ||
| 
						 | 
					157a612f34 | ||
| 
						 | 
					42c890ad50 | ||
| 
						 | 
					48638a18f2 | ||
| 
						 | 
					fae0640bba | ||
| 
						 | 
					23b37bb912 | ||
| 
						 | 
					07135fea91 | ||
| 
						 | 
					1a3c394fe4 | ||
| 
						 | 
					38ec37ed19 | ||
| 
						 | 
					738ae98f2f | ||
| 
						 | 
					99d1e83882 | ||
| 
						 | 
					d71be7e239 | ||
| 
						 | 
					c0b9325efb | ||
| 
						 | 
					538fe6f158 | ||
| 
						 | 
					965d1a52b1 | ||
| 
						 | 
					6be73692ba | ||
| 
						 | 
					f694c168d3 | ||
| 
						 | 
					b7c24dcafa | ||
| 
						 | 
					67a1c4a207 | ||
| 
						 | 
					b188f689ea | ||
| 
						 | 
					6f354c500c | ||
| 
						 | 
					8ba5d36801 | ||
| 
						 | 
					6822a95cc9 | ||
| 
						 | 
					fcc4647379 | ||
| 
						 | 
					0305ee9502 | ||
| 
						 | 
					0e527e8ec0 | ||
| 
						 | 
					91b2dc36c2 | ||
| 
						 | 
					3dc7949a86 | ||
| 
						 | 
					5cf625ef65 | ||
| 
						 | 
					8d5deff2ed | ||
| 
						 | 
					9f6df9a25a | ||
| 
						 | 
					a34733bc88 | ||
| 
						 | 
					7cf02f40e6 | ||
| 
						 | 
					9d2e87f0f3 | ||
| 
						 | 
					034984a784 | ||
| 
						 | 
					d11fcdfec5 | ||
| 
						 | 
					af08b5cd1b | ||
| 
						 | 
					c666917740 | ||
| 
						 | 
					059c3361ca | ||
| 
						 | 
					f18fdcec8c | ||
| 
						 | 
					18d6aa2a34 | ||
| 
						 | 
					db4643d271 | ||
| 
						 | 
					9d5b6faf03 | ||
| 
						 | 
					72e0a1ba2d | ||
| 
						 | 
					35d4f3c8d3 | ||
| 
						 | 
					6dfafbf531 | ||
| 
						 | 
					11e7717519 | ||
| 
						 | 
					a4539399d2 | ||
| 
						 | 
					d14be81f18 | ||
| 
						 | 
					44c4366bba | ||
| 
						 | 
					20b115f99f | ||
| 
						 | 
					e99ba5b6ae | ||
| 
						 | 
					12d32ee8f2 | ||
| 
						 | 
					0f423e8b60 | 
							
								
								
									
										10
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							@@ -13,7 +13,7 @@ jobs:
 | 
			
		||||
      - name: Install Node
 | 
			
		||||
        uses: actions/setup-node@v3
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v2.2.1
 | 
			
		||||
        uses: pnpm/action-setup@v2.2.2
 | 
			
		||||
        with:
 | 
			
		||||
          version: 6.20.1
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
@@ -39,9 +39,9 @@ jobs:
 | 
			
		||||
      - name: Checkout code
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
      - name: Build images
 | 
			
		||||
        run: docker-compose -f e2e/docker-compose.yml build
 | 
			
		||||
        run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
 | 
			
		||||
      - name: Run tests
 | 
			
		||||
        run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
 | 
			
		||||
        run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
 | 
			
		||||
  buildx:
 | 
			
		||||
    needs: [go-test, npm-test, int-test]
 | 
			
		||||
    name: Release
 | 
			
		||||
@@ -62,7 +62,7 @@ jobs:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: Build and push
 | 
			
		||||
        uses: docker/build-push-action@v3.0.0
 | 
			
		||||
        uses: docker/build-push-action@v3.1.1
 | 
			
		||||
        with:
 | 
			
		||||
          push: true
 | 
			
		||||
          platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
 | 
			
		||||
@@ -83,7 +83,7 @@ jobs:
 | 
			
		||||
      - name: Install Node
 | 
			
		||||
        uses: actions/setup-node@v3
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v2.2.1
 | 
			
		||||
        uses: pnpm/action-setup@v2.2.2
 | 
			
		||||
        with:
 | 
			
		||||
          version: 6.20.1
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							@@ -27,10 +27,10 @@ jobs:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: Build and push
 | 
			
		||||
        uses: docker/build-push-action@v3.0.0
 | 
			
		||||
        uses: docker/build-push-action@v3.1.1
 | 
			
		||||
        with:
 | 
			
		||||
          push: true
 | 
			
		||||
          platforms: linux/amd64
 | 
			
		||||
          platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
 | 
			
		||||
          tags: ${{ steps.meta.outputs.tags }}
 | 
			
		||||
          build-args: TAG=${{ steps.meta.outputs.version }}
 | 
			
		||||
          labels: ${{ steps.meta.outputs.labels }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							@@ -16,7 +16,7 @@ jobs:
 | 
			
		||||
      - name: Install Node
 | 
			
		||||
        uses: actions/setup-node@v3
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v2.2.1
 | 
			
		||||
        uses: pnpm/action-setup@v2.2.2
 | 
			
		||||
        with:
 | 
			
		||||
          version: 6.20.1
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
@@ -43,8 +43,12 @@ jobs:
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 2
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
        uses: docker/setup-qemu-action@v2.0.0
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v2.0.0
 | 
			
		||||
      - name: Build images
 | 
			
		||||
        run: docker-compose -f e2e/docker-compose.yml build
 | 
			
		||||
        run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
 | 
			
		||||
      - name: Set commit message for push
 | 
			
		||||
        if: github.event_name == 'push'
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -58,4 +62,4 @@ jobs:
 | 
			
		||||
          git log -1 --pretty=%B ${{github.event.pull_request.head.sha}} >> $GITHUB_ENV
 | 
			
		||||
          echo 'EOF' >> $GITHUB_ENV
 | 
			
		||||
      - name: Run tests
 | 
			
		||||
        run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
 | 
			
		||||
        run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -6,3 +6,4 @@ static
 | 
			
		||||
dozzle
 | 
			
		||||
coverage
 | 
			
		||||
.pnpm-debug.log
 | 
			
		||||
.vscode
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -1,7 +1,7 @@
 | 
			
		||||
# Build assets
 | 
			
		||||
FROM node:18-alpine as node
 | 
			
		||||
FROM --platform=$BUILDPLATFORM node:18-alpine as node
 | 
			
		||||
 | 
			
		||||
RUN apk add --no-cache git openssh make g++ util-linux python3 && npm install -g pnpm
 | 
			
		||||
RUN npm install -g pnpm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
WORKDIR /build
 | 
			
		||||
@@ -19,9 +19,9 @@ COPY assets ./assets
 | 
			
		||||
# Install dependencies
 | 
			
		||||
RUN pnpm install -r --offline --prod --ignore-scripts && pnpm build
 | 
			
		||||
 | 
			
		||||
FROM golang:1.18.2-alpine AS builder
 | 
			
		||||
FROM --platform=$BUILDPLATFORM golang:1.19.0-alpine AS builder
 | 
			
		||||
 | 
			
		||||
RUN apk add --no-cache git ca-certificates && mkdir /dozzle
 | 
			
		||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
 | 
			
		||||
 | 
			
		||||
WORKDIR /dozzle
 | 
			
		||||
 | 
			
		||||
@@ -34,15 +34,18 @@ COPY --from=node /build/dist ./dist
 | 
			
		||||
 | 
			
		||||
# Copy all other files
 | 
			
		||||
COPY analytics ./analytics
 | 
			
		||||
COPY healthcheck ./healthcheck
 | 
			
		||||
COPY docker ./docker
 | 
			
		||||
COPY web ./web
 | 
			
		||||
COPY main.go ./
 | 
			
		||||
 | 
			
		||||
# Args
 | 
			
		||||
ARG TAG=dev
 | 
			
		||||
ARG TARGETOS TARGETARCH
 | 
			
		||||
 | 
			
		||||
# Build binary
 | 
			
		||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG"  -o dozzle
 | 
			
		||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG"  -o dozzle
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
FROM scratch
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
								
							@@ -58,6 +58,30 @@ Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo
 | 
			
		||||
        ports:
 | 
			
		||||
          - 9999:8080
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Enabling health check
 | 
			
		||||
 | 
			
		||||
Dozzle doesn't enable healthcheck by default as it adds extra CPU usage. `healthcheck` can be enabled manually.
 | 
			
		||||
 | 
			
		||||
    version: "3"
 | 
			
		||||
    services:
 | 
			
		||||
      dozzle:
 | 
			
		||||
        container_name: dozzle
 | 
			
		||||
        image: amir20/dozzle:latest
 | 
			
		||||
        volumes:
 | 
			
		||||
          - /var/run/docker.sock:/var/run/docker.sock
 | 
			
		||||
        ports:
 | 
			
		||||
          - 8080:8080
 | 
			
		||||
        environment:
 | 
			
		||||
          DOZZLE_LEVEL: trace
 | 
			
		||||
        healthcheck:
 | 
			
		||||
          test: [ "CMD", "/dozzle", "healthcheck" ]
 | 
			
		||||
          interval: 3s
 | 
			
		||||
          timeout: 30s
 | 
			
		||||
          retries: 5
 | 
			
		||||
          start_period: 30s
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#### Security
 | 
			
		||||
 | 
			
		||||
You can control the device Dozzle binds to by passing `--addr` parameter. For example,
 | 
			
		||||
@@ -105,11 +129,8 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
 | 
			
		||||
| `--filter`       | `DOZZLE_FILTER`       | `""`    |
 | 
			
		||||
| `--username`     | `DOZZLE_USERNAME`     | `""`    |
 | 
			
		||||
| `--password`     | `DOZZLE_PASSWORD`     | `""`    |
 | 
			
		||||
| `--key`          | `DOZZLE_KEY`          | `""`    |
 | 
			
		||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false   |
 | 
			
		||||
 | 
			
		||||
Note: When using username and password `DOZZLE_KEY` is required for session management.
 | 
			
		||||
 | 
			
		||||
## Troubleshooting and FAQs
 | 
			
		||||
 | 
			
		||||
<details>
 | 
			
		||||
 
 | 
			
		||||
@@ -69,20 +69,6 @@ const containerStore = useContainerStore();
 | 
			
		||||
const { activeContainers, visibleContainers } = storeToRefs(containerStore);
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  if (smallerScrollbars.value) {
 | 
			
		||||
    document.documentElement.classList.add("has-custom-scrollbars");
 | 
			
		||||
  }
 | 
			
		||||
  switch (lightTheme.value) {
 | 
			
		||||
    case "dark":
 | 
			
		||||
      document.documentElement.setAttribute("data-theme", "dark");
 | 
			
		||||
      break;
 | 
			
		||||
    case "light":
 | 
			
		||||
      document.documentElement.setAttribute("data-theme", "light");
 | 
			
		||||
      break;
 | 
			
		||||
    default:
 | 
			
		||||
      document.documentElement.removeAttribute("data-theme");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  hotkeys("command+k, ctrl+k", (event, handler) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    showFuzzySearch();
 | 
			
		||||
@@ -121,7 +107,7 @@ function showFuzzySearch() {
 | 
			
		||||
    active: true,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
function onResized(e) {
 | 
			
		||||
function onResized(e: any) {
 | 
			
		||||
  if (e.length == 2) {
 | 
			
		||||
    menuWidth.value = e[0].size;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								assets/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								assets/components.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -1,8 +1,10 @@
 | 
			
		||||
// generated by unplugin-vue-components
 | 
			
		||||
// We suggest you to commit this file into source control
 | 
			
		||||
// Read more: https://github.com/vuejs/vue-next/pull/3399
 | 
			
		||||
// Read more: https://github.com/vuejs/core/pull/3399
 | 
			
		||||
import '@vue/runtime-core'
 | 
			
		||||
 | 
			
		||||
export {}
 | 
			
		||||
 | 
			
		||||
declare module '@vue/runtime-core' {
 | 
			
		||||
  export interface GlobalComponents {
 | 
			
		||||
    CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default']
 | 
			
		||||
@@ -11,8 +13,10 @@ declare module '@vue/runtime-core' {
 | 
			
		||||
    ContainerStat: typeof import('./components/ContainerStat.vue')['default']
 | 
			
		||||
    ContainerTitle: typeof import('./components/ContainerTitle.vue')['default']
 | 
			
		||||
    DropdownMenu: typeof import('./components/DropdownMenu.vue')['default']
 | 
			
		||||
    FieldList: typeof import('./components/FieldList.vue')['default']
 | 
			
		||||
    FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
 | 
			
		||||
    InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
 | 
			
		||||
    JSONPayload: typeof import('./components/JSONPayload.vue')['default']
 | 
			
		||||
    LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default']
 | 
			
		||||
    LogContainer: typeof import('./components/LogContainer.vue')['default']
 | 
			
		||||
    LogEventSource: typeof import('./components/LogEventSource.vue')['default']
 | 
			
		||||
@@ -38,5 +42,3 @@ declare module '@vue/runtime-core' {
 | 
			
		||||
    SideMenu: typeof import('./components/SideMenu.vue')['default']
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,34 +1,28 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="is-size-7 is-uppercase columns is-marginless is-mobile">
 | 
			
		||||
  <div class="is-size-7 is-uppercase columns is-marginless is-mobile" v-if="container.stat">
 | 
			
		||||
    <div class="column is-narrow has-text-weight-bold">
 | 
			
		||||
      {{ state }}
 | 
			
		||||
      {{ container.state }}
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="column is-narrow" v-if="stat.memoryUsage !== null">
 | 
			
		||||
    <div class="column is-narrow" v-if="container.stat.memoryUsage !== null">
 | 
			
		||||
      <span class="has-text-weight-light has-spacer">mem</span>
 | 
			
		||||
      <span class="has-text-weight-bold">
 | 
			
		||||
        {{ formatBytes(stat.memoryUsage) }}
 | 
			
		||||
        {{ formatBytes(container.stat.memoryUsage) }}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="column is-narrow" v-if="stat.cpu !== null">
 | 
			
		||||
    <div class="column is-narrow" v-if="container.stat.cpu !== null">
 | 
			
		||||
      <span class="has-text-weight-light has-spacer">load</span>
 | 
			
		||||
      <span class="has-text-weight-bold"> {{ stat.cpu }}% </span>
 | 
			
		||||
      <span class="has-text-weight-bold"> {{ container.stat.cpu }}% </span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ContainerStat } from "@/types/Container";
 | 
			
		||||
import { PropType } from "vue";
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
import { ComputedRef, inject } from "vue";
 | 
			
		||||
import { formatBytes } from "@/utils";
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
  stat: {
 | 
			
		||||
    type: Object as PropType<ContainerStat>,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  state: String,
 | 
			
		||||
});
 | 
			
		||||
const container = inject("container") as ComputedRef<Container>;
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
 
 | 
			
		||||
@@ -9,13 +9,8 @@
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
import { PropType } from "vue";
 | 
			
		||||
defineProps({
 | 
			
		||||
  container: {
 | 
			
		||||
    type: Object as PropType<Container>,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
import { inject, ComputedRef } from "vue";
 | 
			
		||||
const container = inject("container") as ComputedRef<Container>;
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped></style>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										84
									
								
								assets/components/FieldList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								assets/components/FieldList.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <ul v-if="expanded" ref="root">
 | 
			
		||||
    <li v-for="(value, name) in fields">
 | 
			
		||||
      <template v-if="isObject(value)">
 | 
			
		||||
        <span class="has-text-grey">{{ name }}=</span>
 | 
			
		||||
        <field-list
 | 
			
		||||
          :fields="value"
 | 
			
		||||
          :parent-key="parentKey.concat(name)"
 | 
			
		||||
          :visible-keys="visibleKeys"
 | 
			
		||||
          expanded
 | 
			
		||||
        ></field-list>
 | 
			
		||||
      </template>
 | 
			
		||||
      <template v-else-if="Array.isArray(value)">
 | 
			
		||||
        <a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
 | 
			
		||||
        <span class="has-text-grey">{{ name }}=</span>[
 | 
			
		||||
        <span class="has-text-weight-bold" v-for="(item, index) in value">
 | 
			
		||||
          {{ item }}
 | 
			
		||||
          <span v-if="index !== value.length - 1">,</span>
 | 
			
		||||
        </span>
 | 
			
		||||
        ]
 | 
			
		||||
      </template>
 | 
			
		||||
      <template v-else>
 | 
			
		||||
        <a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
 | 
			
		||||
        <span class="has-text-grey">{{ name }}=</span><span class="has-text-weight-bold">{{ value }}</span>
 | 
			
		||||
      </template>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
</template>
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { arrayEquals, isObject } from "@/utils";
 | 
			
		||||
import { nextTick, PropType, ref, toRaw } from "vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  fields: {
 | 
			
		||||
    type: Object as PropType<Record<string, any>>,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  expanded: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: false,
 | 
			
		||||
  },
 | 
			
		||||
  parentKey: {
 | 
			
		||||
    type: Array as PropType<string[]>,
 | 
			
		||||
    default: [],
 | 
			
		||||
  },
 | 
			
		||||
  visibleKeys: {
 | 
			
		||||
    type: Array as PropType<string[][]>,
 | 
			
		||||
    default: [],
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const root = ref<HTMLElement>();
 | 
			
		||||
 | 
			
		||||
async function toggleField(field: string) {
 | 
			
		||||
  const index = fieldIndex(field);
 | 
			
		||||
 | 
			
		||||
  if (index > -1) {
 | 
			
		||||
    props.visibleKeys.splice(index, 1);
 | 
			
		||||
  } else {
 | 
			
		||||
    props.visibleKeys.push(props.parentKey.concat(field));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await nextTick();
 | 
			
		||||
 | 
			
		||||
  root.value?.scrollIntoView({
 | 
			
		||||
    block: "center",
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function hasField(field: string) {
 | 
			
		||||
  return fieldIndex(field) > -1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function fieldIndex(field: string) {
 | 
			
		||||
  const path = props.parentKey.concat(field);
 | 
			
		||||
  return props.visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
ul {
 | 
			
		||||
  margin-left: 2em;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -22,7 +22,7 @@ const root = ref<HTMLElement>();
 | 
			
		||||
const observer = new IntersectionObserver(async (entries) => {
 | 
			
		||||
  if (entries[0].intersectionRatio <= 0) return;
 | 
			
		||||
  if (props.onLoadMore && props.enabled) {
 | 
			
		||||
    const scrollingParent = root.value.closest("[data-scrolling]") || document.documentElement;
 | 
			
		||||
    const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
 | 
			
		||||
    const previousHeight = scrollingParent.scrollHeight;
 | 
			
		||||
    isLoading.value = true;
 | 
			
		||||
    await props.onLoadMore();
 | 
			
		||||
@@ -32,7 +32,7 @@ const observer = new IntersectionObserver(async (entries) => {
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(() => observer.observe(root.value));
 | 
			
		||||
onMounted(() => observer.observe(root.value!));
 | 
			
		||||
onUnmounted(() => observer.disconnect());
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										55
									
								
								assets/components/JSONPayload.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								assets/components/JSONPayload.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <ul class="fields" @click="expanded = !expanded">
 | 
			
		||||
    <li v-for="(value, name) in logEntry.message">
 | 
			
		||||
      <template v-if="value">
 | 
			
		||||
        <span class="has-text-grey">{{ name }}=</span>
 | 
			
		||||
        <span class="has-text-weight-bold" v-html="markSearch(value)"></span>
 | 
			
		||||
      </template>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
  <field-list :fields="logEntry.unfilteredPayload" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
 | 
			
		||||
</template>
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { useSearchFilter } from "@/composables/search";
 | 
			
		||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
 | 
			
		||||
 | 
			
		||||
import { PropType, ref } from "vue";
 | 
			
		||||
 | 
			
		||||
const { markSearch } = useSearchFilter();
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
  logEntry: {
 | 
			
		||||
    type: Object as PropType<VisibleLogEntry>,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  visibleKeys: {
 | 
			
		||||
    type: Array as PropType<string[][]>,
 | 
			
		||||
    default: [],
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const expanded = ref(false);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.fields {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  list-style: none;
 | 
			
		||||
 | 
			
		||||
  &:hover {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    &::after {
 | 
			
		||||
      content: "expand json";
 | 
			
		||||
      color: var(--secondary-color);
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      margin-left: 0.5em;
 | 
			
		||||
      font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  li {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin-left: 1em;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -41,11 +41,11 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, PropType } from "vue";
 | 
			
		||||
import { inject, onMounted, onUnmounted, PropType, ComputedRef } from "vue";
 | 
			
		||||
import hotkeys from "hotkeys-js";
 | 
			
		||||
import config from "@/stores/config";
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
import { useSearchFilter } from "@/composables/search";
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
 | 
			
		||||
const { showSearch } = useSearchFilter();
 | 
			
		||||
 | 
			
		||||
@@ -56,10 +56,6 @@ const props = defineProps({
 | 
			
		||||
    type: Function as PropType<(e: Event) => void>,
 | 
			
		||||
    default: (e: Event) => {},
 | 
			
		||||
  },
 | 
			
		||||
  container: {
 | 
			
		||||
    type: Object as () => Container,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const onHotkey = (event: Event) => {
 | 
			
		||||
@@ -67,6 +63,8 @@ const onHotkey = (event: Event) => {
 | 
			
		||||
  event.preventDefault();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const container = inject("container") as ComputedRef<Container>;
 | 
			
		||||
 | 
			
		||||
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
 | 
			
		||||
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,14 @@
 | 
			
		||||
    <template #header v-if="showTitle">
 | 
			
		||||
      <div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile">
 | 
			
		||||
        <div class="column is-clipped is-paddingless">
 | 
			
		||||
          <container-title :container="container" @close="$emit('close')" />
 | 
			
		||||
          <container-title @close="$emit('close')" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="column is-narrow is-paddingless">
 | 
			
		||||
          <container-stat :stat="container.stat" :state="container.state" v-if="container.stat" />
 | 
			
		||||
          <container-stat v-if="container.stat" />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="mr-2 column is-narrow is-paddingless">
 | 
			
		||||
          <log-actions-toolbar :container="container" :onClearClicked="onClearClicked" />
 | 
			
		||||
          <log-actions-toolbar :onClearClicked="onClearClicked" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="mr-2 column is-narrow is-paddingless" v-if="closable">
 | 
			
		||||
          <button class="delete is-medium" @click="emit('close')"></button>
 | 
			
		||||
@@ -18,13 +18,13 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #default="{ setLoading }">
 | 
			
		||||
      <log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)" />
 | 
			
		||||
      <log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
 | 
			
		||||
    </template>
 | 
			
		||||
  </scrollable-view>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, toRefs } from "vue";
 | 
			
		||||
import { provide, ref, toRefs } from "vue";
 | 
			
		||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
 | 
			
		||||
import { useContainerStore } from "@/stores/container";
 | 
			
		||||
 | 
			
		||||
@@ -54,6 +54,8 @@ const store = useContainerStore();
 | 
			
		||||
 | 
			
		||||
const container = store.currentContainer(id);
 | 
			
		||||
 | 
			
		||||
provide("container", container);
 | 
			
		||||
 | 
			
		||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
 | 
			
		||||
 | 
			
		||||
function onClearClicked() {
 | 
			
		||||
 
 | 
			
		||||
@@ -18,17 +18,6 @@ vi.mock("lodash.debounce", () => ({
 | 
			
		||||
  }),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
vi.mock("@/stores/container", () => ({
 | 
			
		||||
  __esModule: true,
 | 
			
		||||
  useContainerStore() {
 | 
			
		||||
    return {
 | 
			
		||||
      currentContainer(id: Ref<string>) {
 | 
			
		||||
        return computed(() => ({ id: id.value }));
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
vi.mock("@/stores/config", () => ({
 | 
			
		||||
  __esModule: true,
 | 
			
		||||
  default: { base: "" },
 | 
			
		||||
@@ -78,13 +67,16 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
        components: {
 | 
			
		||||
          LogViewer,
 | 
			
		||||
        },
 | 
			
		||||
        provide: {
 | 
			
		||||
          container: computed(() => ({ id: "abc", image: "test:v123" })),
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      slots: {
 | 
			
		||||
        default: `
 | 
			
		||||
        <template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
 | 
			
		||||
        `,
 | 
			
		||||
      },
 | 
			
		||||
      props: { id: "abc" },
 | 
			
		||||
      props: {},
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -111,41 +103,11 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
    const wrapper = createLogEventSource();
 | 
			
		||||
    sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
    sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
      data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
 | 
			
		||||
      data: `{"ts":1560336942.459, "m":"This is a message.", "id":1}`,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const [message, _] = wrapper.vm.messages;
 | 
			
		||||
    const { key, ...messageWithoutKey } = message;
 | 
			
		||||
 | 
			
		||||
    expect(key).toBe("2019-06-12T10:55:42.459034602Z");
 | 
			
		||||
    expect(messageWithoutKey).toMatchSnapshot();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test("should parse messages with loki's timestamp format", async () => {
 | 
			
		||||
    const wrapper = createLogEventSource();
 | 
			
		||||
    sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
    sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ data: `2020-04-27T12:35:43.272974324+02:00 xxxxx` });
 | 
			
		||||
 | 
			
		||||
    const [message, _] = wrapper.vm.messages;
 | 
			
		||||
    const { key, ...messageWithoutKey } = message;
 | 
			
		||||
 | 
			
		||||
    expect(key).toBe("2020-04-27T12:35:43.272974324+02:00");
 | 
			
		||||
    expect(messageWithoutKey).toMatchSnapshot();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test("should pass messages to slot", async () => {
 | 
			
		||||
    const wrapper = createLogEventSource();
 | 
			
		||||
    sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
    sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
      data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
 | 
			
		||||
    });
 | 
			
		||||
    const [message, _] = wrapper.getComponent(LogViewer).vm.messages;
 | 
			
		||||
 | 
			
		||||
    const { key, ...messageWithoutKey } = message;
 | 
			
		||||
 | 
			
		||||
    expect(key).toBe("2019-06-12T10:55:42.459034602Z");
 | 
			
		||||
 | 
			
		||||
    expect(messageWithoutKey).toMatchSnapshot();
 | 
			
		||||
    expect(message).toMatchSnapshot();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe("render html correctly", () => {
 | 
			
		||||
@@ -169,7 +131,7 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
      const wrapper = createLogEventSource();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
 | 
			
		||||
        data: `{"ts":1560336942.459, "m":"This is a message.", "id":1}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await wrapper.vm.$nextTick();
 | 
			
		||||
@@ -180,7 +142,7 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
      const wrapper = createLogEventSource();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-12T10:55:42.459034602Z \x1b[30mblack\x1b[37mwhite`,
 | 
			
		||||
        data: '{"ts":1560336942.459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await wrapper.vm.$nextTick();
 | 
			
		||||
@@ -191,7 +153,7 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
      const wrapper = createLogEventSource();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-12T10:55:42.459034602Z <test>foo bar</test>`,
 | 
			
		||||
        data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await wrapper.vm.$nextTick();
 | 
			
		||||
@@ -202,7 +164,7 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
      const wrapper = createLogEventSource({ hourStyle: "12" });
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
 | 
			
		||||
        data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await wrapper.vm.$nextTick();
 | 
			
		||||
@@ -213,7 +175,7 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
      const wrapper = createLogEventSource({ hourStyle: "24" });
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
 | 
			
		||||
        data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await wrapper.vm.$nextTick();
 | 
			
		||||
@@ -224,10 +186,10 @@ describe("<LogEventSource />", () => {
 | 
			
		||||
      const wrapper = createLogEventSource({ searchFilter: "test" });
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-11T10:55:42.459034602Z Foo bar`,
 | 
			
		||||
        data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
 | 
			
		||||
      });
 | 
			
		||||
      sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
 | 
			
		||||
        data: `2019-06-12T10:55:42.459034602Z This is a test <hi></hi>`,
 | 
			
		||||
        data: `{"ts":1560336942.459, "m":"<test>test bar</test>", "id":1}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await wrapper.vm.$nextTick();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,133 +1,25 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader>
 | 
			
		||||
  <infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
 | 
			
		||||
  <slot :messages="messages"></slot>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { toRefs, ref, watch, onUnmounted } from "vue";
 | 
			
		||||
import debounce from "lodash.debounce";
 | 
			
		||||
import { useEventSource } from "@/composables/eventsource";
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
import { inject, ComputedRef } from "vue";
 | 
			
		||||
 | 
			
		||||
import { LogEntry } from "@/types/LogEntry";
 | 
			
		||||
import InfiniteLoader from "./InfiniteLoader.vue";
 | 
			
		||||
import config from "@/stores/config";
 | 
			
		||||
import { useContainerStore } from "@/stores/container";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  id: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const { id } = toRefs(props);
 | 
			
		||||
const emit = defineEmits(["loading-more"]);
 | 
			
		||||
const store = useContainerStore();
 | 
			
		||||
const container = store.currentContainer(id);
 | 
			
		||||
const container = inject("container") as ComputedRef<Container>;
 | 
			
		||||
const { connect, messages, loadOlderLogs } = useEventSource(container);
 | 
			
		||||
 | 
			
		||||
const messages = ref<LogEntry[]>([]);
 | 
			
		||||
const buffer = ref<LogEntry[]>([]);
 | 
			
		||||
 | 
			
		||||
function flushNow() {
 | 
			
		||||
  messages.value.push(...buffer.value);
 | 
			
		||||
  buffer.value = [];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
 | 
			
		||||
 | 
			
		||||
let es: EventSource | null = null;
 | 
			
		||||
let lastEventId = "";
 | 
			
		||||
 | 
			
		||||
function connect({ clear } = { clear: true }) {
 | 
			
		||||
  es?.close();
 | 
			
		||||
 | 
			
		||||
  if (clear) {
 | 
			
		||||
    flushBuffer.cancel();
 | 
			
		||||
    messages.value = [];
 | 
			
		||||
    buffer.value = [];
 | 
			
		||||
    lastEventId = "";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  es = new EventSource(`${config.base}/api/logs/stream?id=${props.id}&lastEventId=${lastEventId}`);
 | 
			
		||||
  es.addEventListener("container-stopped", () => {
 | 
			
		||||
    es?.close();
 | 
			
		||||
    es = null;
 | 
			
		||||
    buffer.value.push({
 | 
			
		||||
      event: "container-stopped",
 | 
			
		||||
      message: "Container stopped",
 | 
			
		||||
      date: new Date(),
 | 
			
		||||
      key: new Date().toString(),
 | 
			
		||||
    });
 | 
			
		||||
    flushBuffer();
 | 
			
		||||
    flushBuffer.flush();
 | 
			
		||||
  });
 | 
			
		||||
  es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
 | 
			
		||||
  es.onmessage = (e) => {
 | 
			
		||||
    lastEventId = e.lastEventId;
 | 
			
		||||
    if (e.data) {
 | 
			
		||||
      buffer.value.push(parseMessage(e.data));
 | 
			
		||||
      flushBuffer();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function loadOlderLogs() {
 | 
			
		||||
  if (messages.value.length < 300) return;
 | 
			
		||||
 | 
			
		||||
  emit("loading-more", true);
 | 
			
		||||
  const to = messages.value[0].date;
 | 
			
		||||
  const last = messages.value[299].date;
 | 
			
		||||
  const delta = to.getTime() - last.getTime();
 | 
			
		||||
  const from = new Date(to.getTime() + delta);
 | 
			
		||||
  const logs = await (
 | 
			
		||||
    await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
 | 
			
		||||
  ).text();
 | 
			
		||||
  if (logs) {
 | 
			
		||||
    const newMessages = logs
 | 
			
		||||
      .trim()
 | 
			
		||||
      .split("\n")
 | 
			
		||||
      .map((line) => parseMessage(line));
 | 
			
		||||
    messages.value.unshift(...newMessages);
 | 
			
		||||
  }
 | 
			
		||||
  emit("loading-more", false);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function parseMessage(data: String): LogEntry {
 | 
			
		||||
  let i = data.indexOf(" ");
 | 
			
		||||
  if (i == -1) {
 | 
			
		||||
    i = data.length;
 | 
			
		||||
  }
 | 
			
		||||
  const key = data.substring(0, i);
 | 
			
		||||
  const date = new Date(key);
 | 
			
		||||
  const message = data.substring(i + 1);
 | 
			
		||||
  return { key, date, message };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => container.value.state,
 | 
			
		||||
  (newValue, oldValue) => {
 | 
			
		||||
    console.log("LogEventSource: container changed", newValue, oldValue);
 | 
			
		||||
    if (newValue == "running" && newValue != oldValue) {
 | 
			
		||||
      buffer.value.push({
 | 
			
		||||
        event: "container-started",
 | 
			
		||||
        message: "Container started",
 | 
			
		||||
        date: new Date(),
 | 
			
		||||
        key: new Date().toString(),
 | 
			
		||||
      });
 | 
			
		||||
      connect({ clear: false });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
  if (es) {
 | 
			
		||||
    es.close();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
connect();
 | 
			
		||||
watch(id, () => connect());
 | 
			
		||||
const beforeLoading = () => emit("loading-more", true);
 | 
			
		||||
const afterLoading = () => emit("loading-more", false);
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
  clear: () => (messages.value = []),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
 | 
			
		||||
 | 
			
		||||
connect();
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,14 @@
 | 
			
		||||
  <ul class="events" ref="events" :class="{ 'disable-wrap': !softWrap, [size]: true }">
 | 
			
		||||
    <li
 | 
			
		||||
      v-for="(item, index) in filtered"
 | 
			
		||||
      :key="item.key"
 | 
			
		||||
      :data-key="item.key"
 | 
			
		||||
      :key="item.id"
 | 
			
		||||
      :data-key="item.id"
 | 
			
		||||
      :data-event="item.event"
 | 
			
		||||
      :class="{ selected: item.selected }"
 | 
			
		||||
      :class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="line-options" v-show="isSearching()">
 | 
			
		||||
        <dropdown-menu :class="{ 'is-last': index === filtered.length - 1 }" class="is-top minimal">
 | 
			
		||||
          <a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.key}`">
 | 
			
		||||
          <a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.id}`">
 | 
			
		||||
            <div class="level is-justify-content-start">
 | 
			
		||||
              <div class="level-left">
 | 
			
		||||
                <div class="level-item">
 | 
			
		||||
@@ -25,20 +25,27 @@
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="line">
 | 
			
		||||
        <span class="date" v-if="showTimestamp"> <relative-time :date="item.date"></relative-time></span>
 | 
			
		||||
        <span class="text" v-html="colorize(item.message)"></span>
 | 
			
		||||
        <JSONPayload :log-entry="item" :visible-keys="visibleKeys.value" v-if="item.isComplex()"></JSONPayload>
 | 
			
		||||
        <span class="text" v-html="colorize(item.message)" v-if="item.isSimple()"></span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { PropType, ref, toRefs, watch } from "vue";
 | 
			
		||||
import { ComputedRef, inject, PropType, ref, toRefs, watch, toRaw } from "vue";
 | 
			
		||||
import { useRouteHash } from "@vueuse/router";
 | 
			
		||||
import { size, showTimestamp, softWrap } from "@/composables/settings";
 | 
			
		||||
import RelativeTime from "./RelativeTime.vue";
 | 
			
		||||
import AnsiConvertor from "ansi-to-html";
 | 
			
		||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
 | 
			
		||||
import { LogEntry } from "@/types/LogEntry";
 | 
			
		||||
import { useSearchFilter } from "@/composables/search";
 | 
			
		||||
import { useVisibleFilter } from "@/composables/visible";
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
import { persistentVisibleKeys } from "@/utils";
 | 
			
		||||
 | 
			
		||||
import RelativeTime from "./RelativeTime.vue";
 | 
			
		||||
import AnsiConvertor from "ansi-to-html";
 | 
			
		||||
import JSONPayload from "./JSONPayload.vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  messages: {
 | 
			
		||||
@@ -48,18 +55,22 @@ const props = defineProps({
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
 | 
			
		||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
 | 
			
		||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
 | 
			
		||||
 | 
			
		||||
const { messages } = toRefs(props);
 | 
			
		||||
const filtered = filteredMessages(messages);
 | 
			
		||||
let visibleKeys = persistentVisibleKeys(inject("container") as ComputedRef<Container>);
 | 
			
		||||
 | 
			
		||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
 | 
			
		||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
 | 
			
		||||
 | 
			
		||||
const visible = filteredPayload(messages);
 | 
			
		||||
const filtered = filteredMessages(visible);
 | 
			
		||||
 | 
			
		||||
const events = ref<HTMLElement>();
 | 
			
		||||
let lastSelectedItem: LogEntry | undefined = undefined;
 | 
			
		||||
function handleJumpLineSelected(e: Event, item: LogEntry) {
 | 
			
		||||
  if (lastSelectedItem) {
 | 
			
		||||
    lastSelectedItem.selected = false;
 | 
			
		||||
  }
 | 
			
		||||
  lastSelectedItem = item;
 | 
			
		||||
  item.selected = true;
 | 
			
		||||
let lastSelectedItem = ref<VisibleLogEntry>();
 | 
			
		||||
 | 
			
		||||
function handleJumpLineSelected(e: Event, item: VisibleLogEntry) {
 | 
			
		||||
  lastSelectedItem.value = item;
 | 
			
		||||
  resetSearch();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -84,6 +95,13 @@ watch(
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .text {
 | 
			
		||||
    white-space: pre-wrap;
 | 
			
		||||
    &::before {
 | 
			
		||||
      content: " ";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & > li {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
@@ -95,7 +113,7 @@ watch(
 | 
			
		||||
    &:nth-child(odd) {
 | 
			
		||||
      background-color: rgba(125, 125, 125, 0.08);
 | 
			
		||||
    }
 | 
			
		||||
     &[data-event="container-stopped"] {
 | 
			
		||||
    &[data-event="container-stopped"] {
 | 
			
		||||
      color: #f14668;
 | 
			
		||||
    }
 | 
			
		||||
    &[data-event="container-started"] {
 | 
			
		||||
@@ -167,13 +185,6 @@ watch(
 | 
			
		||||
  border-radius: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text {
 | 
			
		||||
  white-space: pre-wrap;
 | 
			
		||||
  &::before {
 | 
			
		||||
    content: " ";
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:deep(mark) {
 | 
			
		||||
  border-radius: 2px;
 | 
			
		||||
  background-color: var(--secondary-color);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <log-event-source ref="source" :id="id" #default="{ messages }" @loading-more="emit('loading-more', $event)">
 | 
			
		||||
  <log-event-source ref="source" #default="{ messages }" @loading-more="emit('loading-more', $event)">
 | 
			
		||||
    <log-viewer :messages="messages"></log-viewer>
 | 
			
		||||
  </log-event-source>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -7,12 +7,6 @@
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import LogViewer from "./LogViewer.vue";
 | 
			
		||||
import { ref } from "vue";
 | 
			
		||||
defineProps({
 | 
			
		||||
  id: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(["loading-more"]);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@
 | 
			
		||||
 | 
			
		||||
    <div class="is-scrollbar-notification">
 | 
			
		||||
      <transition name="fade">
 | 
			
		||||
        <button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
 | 
			
		||||
        <button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom()" v-show="paused">
 | 
			
		||||
          <mdi-light-chevron-double-down />
 | 
			
		||||
        </button>
 | 
			
		||||
      </transition>
 | 
			
		||||
@@ -24,61 +24,51 @@
 | 
			
		||||
  </section>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    scrollable: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      default: true,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, ref } from "vue";
 | 
			
		||||
 | 
			
		||||
  name: "ScrollableView",
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      paused: false,
 | 
			
		||||
      hasMore: false,
 | 
			
		||||
      loading: false,
 | 
			
		||||
      mutationObserver: null,
 | 
			
		||||
      intersectionObserver: null,
 | 
			
		||||
    };
 | 
			
		||||
defineProps({
 | 
			
		||||
  scrollable: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: true,
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    const { scrollableContent } = this.$refs;
 | 
			
		||||
    this.mutationObserver = new MutationObserver((e) => {
 | 
			
		||||
      if (!this.paused) {
 | 
			
		||||
        this.scrollToBottom("instant");
 | 
			
		||||
      } else {
 | 
			
		||||
        const record = e[e.length - 1];
 | 
			
		||||
        if (
 | 
			
		||||
          record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]
 | 
			
		||||
        ) {
 | 
			
		||||
          this.hasMore = true;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    this.mutationObserver.observe(scrollableContent, { childList: true, subtree: true });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
    this.intersectionObserver = new IntersectionObserver(
 | 
			
		||||
      (entries) => (this.paused = entries[0].intersectionRatio == 0),
 | 
			
		||||
      { threshholds: [0, 1], rootMargin: "80px 0px" }
 | 
			
		||||
    );
 | 
			
		||||
    this.intersectionObserver.observe(this.$refs.scrollObserver);
 | 
			
		||||
  },
 | 
			
		||||
  beforeUnmount() {
 | 
			
		||||
    this.mutationObserver.disconnect();
 | 
			
		||||
    this.intersectionObserver.disconnect();
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    scrollToBottom(behavior = "instant") {
 | 
			
		||||
      this.$refs.scrollObserver.scrollIntoView({ behavior });
 | 
			
		||||
      this.hasMore = false;
 | 
			
		||||
    },
 | 
			
		||||
    setLoading(loading) {
 | 
			
		||||
      this.loading = loading;
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
const paused = ref(false);
 | 
			
		||||
const hasMore = ref(false);
 | 
			
		||||
const loading = ref(false);
 | 
			
		||||
const scrollObserver = ref<HTMLElement>();
 | 
			
		||||
const scrollableContent = ref<HTMLElement>();
 | 
			
		||||
 | 
			
		||||
const mutationObserver = new MutationObserver((e) => {
 | 
			
		||||
  if (!paused.value) {
 | 
			
		||||
    scrollToBottom();
 | 
			
		||||
  } else {
 | 
			
		||||
    const record = e[e.length - 1];
 | 
			
		||||
    if (record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
 | 
			
		||||
      hasMore.value = true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const intersectionObserver = new IntersectionObserver((entries) => (paused.value = entries[0].intersectionRatio == 0), {
 | 
			
		||||
  threshholds: [0, 1],
 | 
			
		||||
  rootMargin: "80px 0px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  mutationObserver.observe(scrollableContent.value!, { childList: true, subtree: true });
 | 
			
		||||
  intersectionObserver.observe(scrollObserver.value!);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
 | 
			
		||||
  scrollObserver.value?.scrollIntoView({ behavior });
 | 
			
		||||
  hasMore.value = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setLoading(value: boolean) {
 | 
			
		||||
  loading.value = value;
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
section {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,201 +1,225 @@
 | 
			
		||||
// Vitest Snapshot v1
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
  <li data-key=\\"2019-06-12T23:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T23:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 11:55:42 PM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
  <li data-key=\\"2019-06-12T23:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T23:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 23:55:42</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
  <li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\">\\"This is a message.\\"</span></div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\">This is a message.</span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = `
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
  <li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span></div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
  <li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\">This is a <mark>test</mark> <hi></hi></span></div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><<mark>test</mark>>foo bar</test></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><<mark>test</mark>>test bar</test></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
  <li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
  <li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
    <div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
      <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
        <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
 | 
			
		||||
        <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
 | 
			
		||||
          <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
              <div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                <div class=\\"level-left\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
 | 
			
		||||
                      <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
 | 
			
		||||
                    </svg></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-28f125ea=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
 | 
			
		||||
                <div class=\\"level-right\\" data-v-cce5b553=\\"\\">
 | 
			
		||||
                  <div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></div>
 | 
			
		||||
    <div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
 | 
			
		||||
      <!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > renders correctly 1`] = `
 | 
			
		||||
"<div class=\\"infinte-loader\\" data-v-48dce4fc=\\"\\">
 | 
			
		||||
  <div class=\\"spinner\\" data-v-48dce4fc=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
    <div class=\\"bounce1\\" data-v-48dce4fc=\\"\\"></div>
 | 
			
		||||
    <div class=\\"bounce2\\" data-v-48dce4fc=\\"\\"></div>
 | 
			
		||||
    <div class=\\"bounce3\\" data-v-48dce4fc=\\"\\"></div>
 | 
			
		||||
"<div class=\\"infinte-loader\\" data-v-1cd63c6e=\\"\\">
 | 
			
		||||
  <div class=\\"spinner\\" data-v-1cd63c6e=\\"\\" style=\\"display: none;\\">
 | 
			
		||||
    <div class=\\"bounce1\\" data-v-1cd63c6e=\\"\\"></div>
 | 
			
		||||
    <div class=\\"bounce2\\" data-v-1cd63c6e=\\"\\"></div>
 | 
			
		||||
    <div class=\\"bounce3\\" data-v-1cd63c6e=\\"\\"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"></ul>"
 | 
			
		||||
<ul class=\\"events medium\\" data-v-cce5b553=\\"\\"></ul>"
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > should parse messages 1`] = `
 | 
			
		||||
{
 | 
			
		||||
  "date": 2019-06-12T10:55:42.459Z,
 | 
			
		||||
  "message": "\\"This is a message.\\"",
 | 
			
		||||
}
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > should parse messages with loki's timestamp format 1`] = `
 | 
			
		||||
{
 | 
			
		||||
  "date": 2020-04-27T10:35:43.272Z,
 | 
			
		||||
  "message": "xxxxx",
 | 
			
		||||
}
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
exports[`<LogEventSource /> > should pass messages to slot 1`] = `
 | 
			
		||||
{
 | 
			
		||||
  "date": 2019-06-12T10:55:42.459Z,
 | 
			
		||||
  "message": "\\"This is a message.\\"",
 | 
			
		||||
  "id": 1,
 | 
			
		||||
  "message": "This is a message.",
 | 
			
		||||
}
 | 
			
		||||
`;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										111
									
								
								assets/composables/eventsource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								assets/composables/eventsource.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,111 @@
 | 
			
		||||
import { ref, watch, onUnmounted, ComputedRef } from "vue";
 | 
			
		||||
import debounce from "lodash.debounce";
 | 
			
		||||
 | 
			
		||||
import type { LogEntry, LogEvent } from "@/types/LogEntry";
 | 
			
		||||
 | 
			
		||||
import config from "@/stores/config";
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
 | 
			
		||||
function parseMessage(data: string): LogEntry {
 | 
			
		||||
  const e = JSON.parse(data) as LogEvent;
 | 
			
		||||
 | 
			
		||||
  const id = e.id;
 | 
			
		||||
  const date = new Date(e.ts * 1000);
 | 
			
		||||
  return { id, date, message: e.m };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useEventSource(container: ComputedRef<Container>) {
 | 
			
		||||
  const messages = ref<LogEntry[]>([]);
 | 
			
		||||
  const buffer = ref<LogEntry[]>([]);
 | 
			
		||||
 | 
			
		||||
  function flushNow() {
 | 
			
		||||
    messages.value.push(...buffer.value);
 | 
			
		||||
    buffer.value = [];
 | 
			
		||||
  }
 | 
			
		||||
  const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
 | 
			
		||||
  let es: EventSource | null = null;
 | 
			
		||||
  let lastEventId = "";
 | 
			
		||||
 | 
			
		||||
  function connect({ clear } = { clear: true }) {
 | 
			
		||||
    es?.close();
 | 
			
		||||
 | 
			
		||||
    if (clear) {
 | 
			
		||||
      flushBuffer.cancel();
 | 
			
		||||
      messages.value = [];
 | 
			
		||||
      buffer.value = [];
 | 
			
		||||
      lastEventId = "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    es = new EventSource(`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}`);
 | 
			
		||||
    es.addEventListener("container-stopped", () => {
 | 
			
		||||
      es?.close();
 | 
			
		||||
      es = null;
 | 
			
		||||
      buffer.value.push({
 | 
			
		||||
        event: "container-stopped",
 | 
			
		||||
        message: "Container stopped",
 | 
			
		||||
        date: new Date(),
 | 
			
		||||
        id: new Date().getTime(),
 | 
			
		||||
      });
 | 
			
		||||
      flushBuffer();
 | 
			
		||||
      flushBuffer.flush();
 | 
			
		||||
    });
 | 
			
		||||
    es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
 | 
			
		||||
    es.onmessage = (e) => {
 | 
			
		||||
      lastEventId = e.lastEventId;
 | 
			
		||||
      if (e.data) {
 | 
			
		||||
        buffer.value.push(parseMessage(e.data));
 | 
			
		||||
        flushBuffer();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
 | 
			
		||||
    if (messages.value.length < 300) return;
 | 
			
		||||
 | 
			
		||||
    beforeLoading();
 | 
			
		||||
    const to = messages.value[0].date;
 | 
			
		||||
    const last = messages.value[299].date;
 | 
			
		||||
    const delta = to.getTime() - last.getTime();
 | 
			
		||||
    const from = new Date(to.getTime() + delta);
 | 
			
		||||
    const logs = await (
 | 
			
		||||
      await fetch(`${config.base}/api/logs?id=${container.value.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
 | 
			
		||||
    ).text();
 | 
			
		||||
    if (logs) {
 | 
			
		||||
      const newMessages = logs
 | 
			
		||||
        .trim()
 | 
			
		||||
        .split("\n")
 | 
			
		||||
        .map((line) => parseMessage(line));
 | 
			
		||||
      messages.value.unshift(...newMessages);
 | 
			
		||||
    }
 | 
			
		||||
    afterLoading();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  watch(
 | 
			
		||||
    () => container.value.state,
 | 
			
		||||
    (newValue, oldValue) => {
 | 
			
		||||
      console.log("LogEventSource: container changed", newValue, oldValue);
 | 
			
		||||
      if (newValue == "running" && newValue != oldValue) {
 | 
			
		||||
        buffer.value.push({
 | 
			
		||||
          event: "container-started",
 | 
			
		||||
          message: "Container started",
 | 
			
		||||
          date: new Date(),
 | 
			
		||||
          id: new Date().getTime(),
 | 
			
		||||
        });
 | 
			
		||||
        connect({ clear: false });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  onUnmounted(() => {
 | 
			
		||||
    if (es) {
 | 
			
		||||
      es.close();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  watch(
 | 
			
		||||
    () => container.value.id,
 | 
			
		||||
    () => connect()
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return { connect, messages, loadOlderLogs };
 | 
			
		||||
}
 | 
			
		||||
@@ -3,7 +3,20 @@ import { ref, computed, Ref } from "vue";
 | 
			
		||||
const searchFilter = ref<string>("");
 | 
			
		||||
const showSearch = ref(false);
 | 
			
		||||
 | 
			
		||||
import type { LogEntry } from "@/types/LogEntry";
 | 
			
		||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
 | 
			
		||||
 | 
			
		||||
function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
 | 
			
		||||
  for (const key in record) {
 | 
			
		||||
    const value = record[key];
 | 
			
		||||
    if (typeof value === "string" && regex.test(value)) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    if (Array.isArray(value) && matchRecord(value, regex)) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useSearchFilter() {
 | 
			
		||||
  const regex = computed(() => {
 | 
			
		||||
@@ -11,11 +24,18 @@ export function useSearchFilter() {
 | 
			
		||||
    return isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  function filteredMessages(messages: Ref<LogEntry[]>) {
 | 
			
		||||
  function filteredMessages(messages: Ref<VisibleLogEntry[]>) {
 | 
			
		||||
    return computed(() => {
 | 
			
		||||
      if (searchFilter && searchFilter.value) {
 | 
			
		||||
      if (searchFilter.value) {
 | 
			
		||||
        try {
 | 
			
		||||
          return messages.value.filter((d) => d.message.match(regex.value));
 | 
			
		||||
          return messages.value.filter((d) => {
 | 
			
		||||
            if (d.isSimple()) {
 | 
			
		||||
              return regex.value.test(d.message);
 | 
			
		||||
            } else if (d.isComplex()) {
 | 
			
		||||
              return matchRecord(d.message, regex.value);
 | 
			
		||||
            }
 | 
			
		||||
            throw new Error("Unknown message type");
 | 
			
		||||
          });
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          if (e instanceof SyntaxError) {
 | 
			
		||||
            console.info(`Ignoring SyntaxError from search.`, e);
 | 
			
		||||
@@ -29,11 +49,17 @@ export function useSearchFilter() {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function markSearch(log: string) {
 | 
			
		||||
    if (searchFilter && searchFilter.value) {
 | 
			
		||||
      return log.replace(regex.value, `<mark>$&</mark>`);
 | 
			
		||||
  function markSearch(log: string): string;
 | 
			
		||||
  function markSearch(log: string[]): string[];
 | 
			
		||||
  function markSearch(log: string | string[]) {
 | 
			
		||||
    if (!searchFilter.value) {
 | 
			
		||||
      return log;
 | 
			
		||||
    }
 | 
			
		||||
    return log;
 | 
			
		||||
    if (Array.isArray(log)) {
 | 
			
		||||
      return log.map((d) => markSearch(d));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return log.toString().replace(regex.value, (match) => `<mark>${match}</mark>`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function resetSearch() {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								assets/composables/visible.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								assets/composables/visible.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
import { LogEntry } from "@/types/LogEntry";
 | 
			
		||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
 | 
			
		||||
import { computed, ComputedRef, Ref } from "vue";
 | 
			
		||||
 | 
			
		||||
export function useVisibleFilter(visibleKeys: ComputedRef<Ref<string[][]>>) {
 | 
			
		||||
  function filteredPayload(messages: Ref<LogEntry[]>) {
 | 
			
		||||
    return computed(() => {
 | 
			
		||||
      return messages.value.map((d) => new VisibleLogEntry(d, visibleKeys.value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { filteredPayload };
 | 
			
		||||
}
 | 
			
		||||
@@ -17,6 +17,7 @@ $menu-item-hover-color: var(--menu-item-hover-color);
 | 
			
		||||
 | 
			
		||||
$text-strong: var(--text-strong-color);
 | 
			
		||||
$text: var(--text-color);
 | 
			
		||||
$text-light: var(--text-light-color);
 | 
			
		||||
 | 
			
		||||
$panel-heading-background-color: var(--panel-heading-background-color);
 | 
			
		||||
$panel-heading-color: var(--panel-heading-color);
 | 
			
		||||
@@ -38,8 +39,7 @@ $light-toolbar-color: rgba($grey-darker, 0.7);
 | 
			
		||||
@import "@oruga-ui/theme-bulma/dist/scss/components/skeleton.scss";
 | 
			
		||||
@import "splitpanes/dist/splitpanes.css";
 | 
			
		||||
 | 
			
		||||
html,
 | 
			
		||||
[data-theme="dark"] {
 | 
			
		||||
@mixin dark {
 | 
			
		||||
  --scheme-main: #{$black};
 | 
			
		||||
  --scheme-main-bis: #{$black-bis};
 | 
			
		||||
  --scheme-main-ter: #{$black-ter};
 | 
			
		||||
@@ -64,93 +64,57 @@ html,
 | 
			
		||||
 | 
			
		||||
  --text-strong-color: #{$grey-lightest};
 | 
			
		||||
  --text-color: #{$grey-lighter};
 | 
			
		||||
  --text-light-color: #{$grey};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@mixin light {
 | 
			
		||||
  --scheme-main: #{$white};
 | 
			
		||||
  --scheme-main-bis: #{$white-bis};
 | 
			
		||||
  --scheme-main-ter: #{$white-ter};
 | 
			
		||||
 | 
			
		||||
  --border-color: #{$grey-lighter};
 | 
			
		||||
  --border-hover-color: var(--secondary-color);
 | 
			
		||||
  --logo-color: #{$grey-darker};
 | 
			
		||||
 | 
			
		||||
  --primary-color: #{$turquoise};
 | 
			
		||||
  --secondary-color: #d8f0ca;
 | 
			
		||||
 | 
			
		||||
  --body-background-color: #{$white-bis};
 | 
			
		||||
  --action-toolbar-background-color: #{$light-toolbar-color};
 | 
			
		||||
  --body-color: #{$grey-darker};
 | 
			
		||||
 | 
			
		||||
  --menu-item-color: #{$grey-dark};
 | 
			
		||||
  --menu-item-hover-background-color: #eee8e7;
 | 
			
		||||
  --menu-item-hover-color: #{black-ter};
 | 
			
		||||
 | 
			
		||||
  --panel-heading-background-color: var(--secondary-color);
 | 
			
		||||
  --panel-heading-color: var(--text-strong-color);
 | 
			
		||||
 | 
			
		||||
  --text-strong-color: #{$grey-dark};
 | 
			
		||||
  --text-color: #{$grey-darker};
 | 
			
		||||
  --text-light-color: #{$grey};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] {
 | 
			
		||||
  @include dark;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="light"] {
 | 
			
		||||
  @include light;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (prefers-color-scheme: dark) {
 | 
			
		||||
  html {
 | 
			
		||||
  --scheme-main: #{$black};
 | 
			
		||||
  --scheme-main-bis: #{$black-bis};
 | 
			
		||||
  --scheme-main-ter: #{$black-ter};
 | 
			
		||||
 | 
			
		||||
  --border-color: #{$grey-darker};
 | 
			
		||||
  --border-hover-color: var(--secondary-color);
 | 
			
		||||
  --logo-color: var(--secondary-color);
 | 
			
		||||
 | 
			
		||||
  --primary-color: #{$turquoise};
 | 
			
		||||
  --secondary-color: #{$yellow};
 | 
			
		||||
 | 
			
		||||
  --body-background-color: #{$black-bis};
 | 
			
		||||
  --action-toolbar-background-color: #{$dark-toolbar-color};
 | 
			
		||||
 | 
			
		||||
  --menu-item-active-background-color: var(--primary-color);
 | 
			
		||||
  --menu-item-color: hsl(0, 6%, 87%);
 | 
			
		||||
  --menu-item-hover-background-color: #{$white-ter};
 | 
			
		||||
  --menu-item-hover-color: #{$black-ter};
 | 
			
		||||
 | 
			
		||||
  --panel-heading-background-color: var(--secondary-color);
 | 
			
		||||
  --panel-heading-color: var(--scheme-main-bis);
 | 
			
		||||
 | 
			
		||||
  --text-strong-color: #{$grey-lightest};
 | 
			
		||||
  --text-color: #{$grey-lighter};
 | 
			
		||||
    @include dark;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (prefers-color-scheme: light) {
 | 
			
		||||
  html {
 | 
			
		||||
  --scheme-main: #{$white};
 | 
			
		||||
  --scheme-main-bis: #{$white-bis};
 | 
			
		||||
  --scheme-main-ter: #{$white-ter};
 | 
			
		||||
 | 
			
		||||
  --border-color: #{$grey-lighter};
 | 
			
		||||
  --border-hover-color: var(--secondary-color);
 | 
			
		||||
  --logo-color: #{$grey-darker};
 | 
			
		||||
 | 
			
		||||
  --primary-color: #{$turquoise};
 | 
			
		||||
  --secondary-color: #d8f0ca;
 | 
			
		||||
 | 
			
		||||
  --body-background-color: #{$white-bis};
 | 
			
		||||
  --action-toolbar-background-color: #{$light-toolbar-color};
 | 
			
		||||
  --body-color: #{$grey-darker};
 | 
			
		||||
 | 
			
		||||
  --menu-item-color: #{$grey-dark};
 | 
			
		||||
  --menu-item-hover-background-color: #eee8e7;
 | 
			
		||||
  --menu-item-hover-color: #{black-ter};
 | 
			
		||||
 | 
			
		||||
  --panel-heading-background-color: var(--secondary-color);
 | 
			
		||||
  --panel-heading-color: var(--text-strong-color);
 | 
			
		||||
 | 
			
		||||
  --text-strong-color: #{$grey-dark};
 | 
			
		||||
  --text-color: #{$grey-darker};
 | 
			
		||||
    @include light;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="light"] {
 | 
			
		||||
  --scheme-main: #{$white};
 | 
			
		||||
  --scheme-main-bis: #{$white-bis};
 | 
			
		||||
  --scheme-main-ter: #{$white-ter};
 | 
			
		||||
 | 
			
		||||
  --border-color: #{$grey-lighter};
 | 
			
		||||
  --border-hover-color: var(--secondary-color);
 | 
			
		||||
  --logo-color: #{$grey-darker};
 | 
			
		||||
 | 
			
		||||
  --primary-color: #{$turquoise};
 | 
			
		||||
  --secondary-color: #d8f0ca;
 | 
			
		||||
 | 
			
		||||
  --body-background-color: #{$white-bis};
 | 
			
		||||
  --action-toolbar-background-color: #{$light-toolbar-color};
 | 
			
		||||
  --body-color: #{$grey-darker};
 | 
			
		||||
 | 
			
		||||
  --menu-item-color: #{$grey-dark};
 | 
			
		||||
  --menu-item-hover-background-color: #eee8e7;
 | 
			
		||||
  --menu-item-hover-color: #{black-ter};
 | 
			
		||||
 | 
			
		||||
  --panel-heading-background-color: var(--secondary-color);
 | 
			
		||||
  --panel-heading-color: var(--text-strong-color);
 | 
			
		||||
 | 
			
		||||
  --text-strong-color: #{$grey-dark};
 | 
			
		||||
  --text-color: #{$grey-darker};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
html {
 | 
			
		||||
  overflow-x: unset;
 | 
			
		||||
  overflow-y: unset;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								assets/types/Container.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								assets/types/Container.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -4,6 +4,7 @@ export interface Container {
 | 
			
		||||
  readonly image: string;
 | 
			
		||||
  readonly name: string;
 | 
			
		||||
  readonly status: string;
 | 
			
		||||
  readonly command: string;
 | 
			
		||||
  state: "created" | "running" | "exited" | "dead" | "paused" | "restarting";
 | 
			
		||||
  stat?: ContainerStat;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								assets/types/LogEntry.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								assets/types/LogEntry.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -1,7 +1,16 @@
 | 
			
		||||
export interface LogEntry {
 | 
			
		||||
  date: Date;
 | 
			
		||||
  message: string;
 | 
			
		||||
  key: string;
 | 
			
		||||
  readonly date: Date;
 | 
			
		||||
  readonly message: string | JSONObject;
 | 
			
		||||
  readonly id: number;
 | 
			
		||||
  event?: string;
 | 
			
		||||
  selected?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface LogEvent {
 | 
			
		||||
  readonly m: string | JSONObject;
 | 
			
		||||
  readonly ts: number;
 | 
			
		||||
  readonly id: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
 | 
			
		||||
export type JSONObject = { [x: string]: JSONValue };
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										54
									
								
								assets/types/VisibleLogEntry.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								assets/types/VisibleLogEntry.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
import { computed, ComputedRef, Ref } from "vue";
 | 
			
		||||
import { flattenJSON, getDeep } from "@/utils";
 | 
			
		||||
import type { JSONObject, LogEntry } from "./LogEntry";
 | 
			
		||||
 | 
			
		||||
export class VisibleLogEntry implements LogEntry {
 | 
			
		||||
  private readonly entry: LogEntry;
 | 
			
		||||
  filteredMessage: undefined | ComputedRef<Record<string, any>>;
 | 
			
		||||
 | 
			
		||||
  constructor(entry: LogEntry, visibleKeys: Ref<string[][]>) {
 | 
			
		||||
    this.entry = entry;
 | 
			
		||||
    this.filteredMessage = undefined;
 | 
			
		||||
    if (this.isComplex()) {
 | 
			
		||||
      const message = this.message;
 | 
			
		||||
      this.filteredMessage = computed(() => {
 | 
			
		||||
        if (!visibleKeys.value.length) {
 | 
			
		||||
          return flattenJSON(message);
 | 
			
		||||
        } else {
 | 
			
		||||
          return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public isComplex(): this is { message: JSONObject } {
 | 
			
		||||
    return typeof this.entry.message === "object";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public isSimple(): this is { message: string } {
 | 
			
		||||
    return !this.isComplex();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get unfilteredPayload(): JSONObject {
 | 
			
		||||
    if (typeof this.entry.message === "string") {
 | 
			
		||||
      throw new Error("Cannot get unfiltered payload of a simple message");
 | 
			
		||||
    }
 | 
			
		||||
    return this.entry.message;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get date(): Date {
 | 
			
		||||
    return this.entry.date;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get message(): string | JSONObject {
 | 
			
		||||
    return this.filteredMessage?.value ?? this.entry.message;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get id(): number {
 | 
			
		||||
    return this.entry.id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get event(): string | undefined {
 | 
			
		||||
    return this.entry.event;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +1,7 @@
 | 
			
		||||
import { Container } from "@/types/Container";
 | 
			
		||||
import { useStorage } from "@vueuse/core";
 | 
			
		||||
import { computed, ComputedRef } from "vue";
 | 
			
		||||
 | 
			
		||||
export function formatBytes(bytes: number, decimals = 2) {
 | 
			
		||||
  if (bytes === 0) return "0 Bytes";
 | 
			
		||||
  const k = 1024;
 | 
			
		||||
@@ -6,3 +10,38 @@ export function formatBytes(bytes: number, decimals = 2) {
 | 
			
		||||
  const i = Math.floor(Math.log(bytes) / Math.log(k));
 | 
			
		||||
  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getDeep(obj: Record<string, any>, path: string[]) {
 | 
			
		||||
  return path.reduce((acc, key) => acc?.[key], obj);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isObject(value: any): value is Record<string, any> {
 | 
			
		||||
  return typeof value === "object" && value !== null && !Array.isArray(value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
 | 
			
		||||
  const result: Record<string, any> = {};
 | 
			
		||||
  Object.keys(obj).forEach((key) => {
 | 
			
		||||
    const value = obj[key];
 | 
			
		||||
    const newPath = path.concat(key);
 | 
			
		||||
    if (isObject(value)) {
 | 
			
		||||
      Object.assign(result, flattenJSON(value, newPath));
 | 
			
		||||
    } else {
 | 
			
		||||
      result[newPath.join(".")] = value;
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function arrayEquals(a: string[], b: string[]): boolean {
 | 
			
		||||
  return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function persistentVisibleKeys(container: ComputedRef<Container>) {
 | 
			
		||||
  return computed(() => useStorage(stripVersion(container.value.image) + ":" + container.value.command, []));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function stripVersion(label: string) {
 | 
			
		||||
  const [name, _] = label.split(":");
 | 
			
		||||
  return name;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,7 @@ type dockerProxy interface {
 | 
			
		||||
	Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error)
 | 
			
		||||
	ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
 | 
			
		||||
	ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error)
 | 
			
		||||
	Ping(ctx context.Context) (types.Ping, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Client is a proxy around the docker client
 | 
			
		||||
@@ -38,6 +39,7 @@ type Client interface {
 | 
			
		||||
	Events(context.Context) (<-chan ContainerEvent, <-chan error)
 | 
			
		||||
	ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time) (io.ReadCloser, error)
 | 
			
		||||
	ContainerStats(context.Context, string, chan<- ContainerStat) error
 | 
			
		||||
	Ping(context.Context) (types.Ping, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewClientWithFilters creates a new instance of Client with docker filters
 | 
			
		||||
@@ -136,14 +138,20 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan
 | 
			
		||||
				log.Errorf("decoder for stats api returned an unknown error %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			ncpus := uint8(v.CPUStats.OnlineCPUs)
 | 
			
		||||
			if ncpus == 0 {
 | 
			
		||||
				ncpus = uint8(len(v.CPUStats.CPUUsage.PercpuUsage))
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var (
 | 
			
		||||
				cpuDelta    = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage)
 | 
			
		||||
				systemDelta = float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage)
 | 
			
		||||
				cpuPercent  = int64((cpuDelta / systemDelta) * float64(len(v.CPUStats.CPUUsage.PercpuUsage)) * 100)
 | 
			
		||||
				cpuPercent  = int64((cpuDelta / systemDelta) * float64(ncpus) * 100)
 | 
			
		||||
				memUsage    = int64(v.MemoryStats.Usage - v.MemoryStats.Stats["cache"])
 | 
			
		||||
				memPercent  = int64(float64(memUsage) / float64(v.MemoryStats.Limit) * 100)
 | 
			
		||||
			)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
			if cpuPercent > 0 || memUsage > 0 {
 | 
			
		||||
				select {
 | 
			
		||||
				case <-ctx.Done():
 | 
			
		||||
@@ -165,6 +173,12 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan
 | 
			
		||||
func (d *dockerClient) ContainerLogs(ctx context.Context, id string, tailSize int, since string) (io.ReadCloser, error) {
 | 
			
		||||
	log.WithField("id", id).WithField("since", since).Debug("streaming logs for container")
 | 
			
		||||
 | 
			
		||||
	if since != "" {
 | 
			
		||||
		if sinceTime, err := time.Parse(time.RFC3339Nano, since); err == nil {
 | 
			
		||||
			since = sinceTime.Add(time.Microsecond).Format(time.RFC3339Nano)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	options := types.ContainerLogsOptions{
 | 
			
		||||
		ShowStdout: true,
 | 
			
		||||
		ShowStderr: true,
 | 
			
		||||
@@ -241,3 +255,7 @@ func (d *dockerClient) ContainerLogsBetweenDates(ctx context.Context, id string,
 | 
			
		||||
 | 
			
		||||
	return newLogReader(reader, containerJSON.Config.Tty), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (d *dockerClient) Ping(ctx context.Context) (types.Ping, error) {
 | 
			
		||||
	return d.cli.Ping(ctx)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,3 +26,9 @@ type ContainerEvent struct {
 | 
			
		||||
	ActorID string `json:"actorId"`
 | 
			
		||||
	Name    string `json:"name"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type LogEvent struct {
 | 
			
		||||
	Message   any    `json:"m,omitempty"`
 | 
			
		||||
	Timestamp int64  `json:"ts"`
 | 
			
		||||
	Id        uint32 `json:"id,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
FROM cypress/included:9.6.1
 | 
			
		||||
FROM cypress/included:10.6.0
 | 
			
		||||
 | 
			
		||||
RUN apt install curl && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
 | 
			
		||||
 | 
			
		||||
@@ -7,5 +7,5 @@ WORKDIR /e2e
 | 
			
		||||
COPY pnpm-lock.yaml ./
 | 
			
		||||
RUN pnpm fetch
 | 
			
		||||
 | 
			
		||||
COPY package.json ./
 | 
			
		||||
COPY package.json tsconfig.json ./
 | 
			
		||||
RUN pnpm install -r --offline
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								e2e/cypress.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								e2e/cypress.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
import { defineConfig } from "cypress";
 | 
			
		||||
import { initPlugin } from '@frsource/cypress-plugin-visual-regression-diff/dist/plugins';
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  fixturesFolder: false,
 | 
			
		||||
  projectId: "8cua4m",
 | 
			
		||||
 | 
			
		||||
  e2e: {
 | 
			
		||||
    setupNodeEvents(on, config) {
 | 
			
		||||
      initPlugin(on, config);
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
{
 | 
			
		||||
  "DOZZLE_DEFAULT": "http://localhost:3000/"
 | 
			
		||||
  "DOZZLE_DEFAULT": "http://localhost:8080/"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "fixturesFolder": false,
 | 
			
		||||
  "projectId": "8cua4m"
 | 
			
		||||
}
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 34 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 35 KiB  | 
@@ -6,7 +6,7 @@ context("Dozzle default mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () =>
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("home screen", () => {
 | 
			
		||||
    cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImageSnapshot();
 | 
			
		||||
    cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImage();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("correct title", () => {
 | 
			
		||||
							
								
								
									
										12
									
								
								e2e/cypress/e2e/dozzle_dark.cy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								e2e/cypress/e2e/dozzle_dark.cy.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
/// <reference types="cypress" />
 | 
			
		||||
 | 
			
		||||
context("Dozzle dark mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    cy.visit("/");
 | 
			
		||||
    cy.window().then((win) => win.document.documentElement.setAttribute("data-theme", "dark"));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("home screen", () => {
 | 
			
		||||
    cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImage();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										5
									
								
								e2e/cypress/fixtures/example.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								e2e/cypress/fixtures/example.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "Using fixtures to represent data",
 | 
			
		||||
  "email": "hello@cypress.io",
 | 
			
		||||
  "body": "Fixtures are a great way to mock data for responses to routes"
 | 
			
		||||
}
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
/// <reference types="cypress" />
 | 
			
		||||
 | 
			
		||||
context.skip("Dozzle light mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
 | 
			
		||||
  before(() => {
 | 
			
		||||
    cy.visit("/settings");
 | 
			
		||||
    cy.contains("Use light theme").click();
 | 
			
		||||
  });
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    cy.visit("/");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("home screen", () => {
 | 
			
		||||
    cy.get("li.running", { timeout: 10000 }).removeDates().matchImageSnapshot();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -1,26 +0,0 @@
 | 
			
		||||
/// <reference types="cypress" />
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
// This example plugins/index.js can be used to load plugins
 | 
			
		||||
//
 | 
			
		||||
// You can change the location of this file or turn off loading
 | 
			
		||||
// the plugins file with the 'pluginsFile' configuration option.
 | 
			
		||||
//
 | 
			
		||||
// You can read more here:
 | 
			
		||||
// https://on.cypress.io/plugins-guide
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
 | 
			
		||||
// This function is called when a project is opened or re-opened (e.g. due to
 | 
			
		||||
// the project's config changing)
 | 
			
		||||
 | 
			
		||||
const { addMatchImageSnapshotPlugin } = require("cypress-image-snapshot/plugin");
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @type {Cypress.PluginConfig}
 | 
			
		||||
 */
 | 
			
		||||
// eslint-disable-next-line no-unused-vars
 | 
			
		||||
module.exports = (on, config) => {
 | 
			
		||||
  // `on` is used to hook into various events Cypress emits
 | 
			
		||||
  // `config` is the resolved Cypress config
 | 
			
		||||
 | 
			
		||||
  addMatchImageSnapshotPlugin(on, config);
 | 
			
		||||
};
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB  | 
@@ -1,5 +1,6 @@
 | 
			
		||||
/// <reference types="cypress" />
 | 
			
		||||
// ***********************************************
 | 
			
		||||
// This example commands.js shows you how to
 | 
			
		||||
// This example commands.ts shows you how to
 | 
			
		||||
// create various custom commands and overwrite
 | 
			
		||||
// existing commands.
 | 
			
		||||
//
 | 
			
		||||
@@ -23,10 +24,17 @@
 | 
			
		||||
//
 | 
			
		||||
// -- This will overwrite an existing command --
 | 
			
		||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
 | 
			
		||||
 | 
			
		||||
import { addMatchImageSnapshotCommand } from "cypress-image-snapshot/command";
 | 
			
		||||
 | 
			
		||||
addMatchImageSnapshotCommand();
 | 
			
		||||
//
 | 
			
		||||
// declare global {
 | 
			
		||||
//   namespace Cypress {
 | 
			
		||||
//     interface Chainable {
 | 
			
		||||
//       login(email: string, password: string): Chainable<void>
 | 
			
		||||
//       drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
 | 
			
		||||
//       dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
 | 
			
		||||
//       visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
 | 
			
		||||
//     }
 | 
			
		||||
//   }
 | 
			
		||||
// }
 | 
			
		||||
 | 
			
		||||
Cypress.Commands.add("removeDates", () => {
 | 
			
		||||
  cy.window().then((win) => win.document.querySelectorAll("time").forEach((el) => el.remove()));
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
// This example support/index.js is processed and
 | 
			
		||||
// This example support/e2e.ts is processed and
 | 
			
		||||
// loaded automatically before your test files.
 | 
			
		||||
//
 | 
			
		||||
// This is a great place to put global configuration and
 | 
			
		||||
@@ -14,7 +14,8 @@
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
 | 
			
		||||
// Import commands.js using ES2015 syntax:
 | 
			
		||||
import "./commands";
 | 
			
		||||
import './commands'
 | 
			
		||||
import '@frsource/cypress-plugin-visual-regression-diff/dist/support';
 | 
			
		||||
 | 
			
		||||
// Alternatively you can use CommonJS syntax:
 | 
			
		||||
// require('./commands')
 | 
			
		||||
							
								
								
									
										14
									
								
								e2e/cypress/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								e2e/cypress/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "../tsconfig.json",
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "noEmit": true,
 | 
			
		||||
    // be explicit about types included
 | 
			
		||||
    // to avoid clashing with Jest types
 | 
			
		||||
    "types": ["cypress"]
 | 
			
		||||
  },
 | 
			
		||||
  "include": [
 | 
			
		||||
    "../node_modules/cypress",
 | 
			
		||||
    "./**/*.ts",
 | 
			
		||||
    "./**/*.js"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
@@ -29,7 +29,7 @@ services:
 | 
			
		||||
    working_dir: /e2e
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ./cypress:/e2e/cypress
 | 
			
		||||
      - ./cypress.json:/e2e/cypress.json
 | 
			
		||||
      - ./cypress.config.ts:/e2e/cypress.config.ts
 | 
			
		||||
    environment:
 | 
			
		||||
      - CYPRESS_DOZZLE_DEFAULT=http://dozzle:8080/
 | 
			
		||||
      - CYPRESS_CUSTOM_DEFAULT=http://custom_base:8080/foobarbase
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
  "scripts": {},
 | 
			
		||||
  "license": "ISC",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "cypress": "^9.5.4",
 | 
			
		||||
    "cypress-image-snapshot": "^4.0.1"
 | 
			
		||||
    "@frsource/cypress-plugin-visual-regression-diff": "^1.9.2",
 | 
			
		||||
    "cypress": "^10.6.0",
 | 
			
		||||
    "typescript": "^4.7.4"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										710
									
								
								e2e/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										710
									
								
								e2e/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										8
									
								
								e2e/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								e2e/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "target": "es5",
 | 
			
		||||
    "lib": ["es5", "dom"],
 | 
			
		||||
    "types": ["cypress", "node"]
 | 
			
		||||
  },
 | 
			
		||||
  "include": ["**/*.ts"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								go.mod
									
									
									
									
									
								
							@@ -5,7 +5,7 @@ require (
 | 
			
		||||
	github.com/alexflint/go-arg v1.4.3
 | 
			
		||||
	github.com/beme/abide v0.0.0-20190723115211-635a09831760
 | 
			
		||||
	github.com/docker/distribution v2.7.1+incompatible // indirect
 | 
			
		||||
	github.com/docker/docker v20.10.16+incompatible
 | 
			
		||||
	github.com/docker/docker v20.10.17+incompatible
 | 
			
		||||
	github.com/docker/go-connections v0.4.0 // indirect
 | 
			
		||||
	github.com/docker/go-units v0.4.0 // indirect
 | 
			
		||||
	github.com/dustin/go-humanize v1.0.0
 | 
			
		||||
@@ -18,13 +18,13 @@ require (
 | 
			
		||||
	github.com/opencontainers/go-digest v1.0.0 // indirect
 | 
			
		||||
	github.com/opencontainers/image-spec v1.0.1 // indirect
 | 
			
		||||
	github.com/sergi/go-diff v1.1.0 // indirect
 | 
			
		||||
	github.com/sirupsen/logrus v1.8.1
 | 
			
		||||
	github.com/spf13/afero v1.8.2
 | 
			
		||||
	github.com/stretchr/objx v0.3.0 // indirect
 | 
			
		||||
	github.com/stretchr/testify v1.7.1
 | 
			
		||||
	github.com/sirupsen/logrus v1.9.0
 | 
			
		||||
	github.com/spf13/afero v1.9.2
 | 
			
		||||
	github.com/stretchr/objx v0.4.0 // indirect
 | 
			
		||||
	github.com/stretchr/testify v1.8.0
 | 
			
		||||
	golang.org/x/net v0.0.0-20211104170005-ce137452f963 // indirect
 | 
			
		||||
	golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 | 
			
		||||
	golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.1 // indirect
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								go.sum
									
									
									
									
									
								
							@@ -62,8 +62,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 | 
			
		||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
 | 
			
		||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 | 
			
		||||
github.com/docker/docker v20.10.16+incompatible h1:2Db6ZR/+FUR3hqPMwnogOPHFn405crbpxvWzKovETOQ=
 | 
			
		||||
github.com/docker/docker v20.10.16+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
			
		||||
github.com/docker/docker v20.10.17+incompatible h1:JYCuMrWaVNophQTOrMMoSwudOVEfcegoZZrleKc1xwE=
 | 
			
		||||
github.com/docker/docker v20.10.17+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
			
		||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
 | 
			
		||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
 | 
			
		||||
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
 | 
			
		||||
@@ -179,21 +179,21 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
 | 
			
		||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 | 
			
		||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 | 
			
		||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 | 
			
		||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
 | 
			
		||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 | 
			
		||||
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
 | 
			
		||||
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
 | 
			
		||||
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
 | 
			
		||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 | 
			
		||||
github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw=
 | 
			
		||||
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
 | 
			
		||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
 | 
			
		||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 | 
			
		||||
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
 | 
			
		||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 | 
			
		||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 | 
			
		||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 | 
			
		||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 | 
			
		||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 | 
			
		||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
 | 
			
		||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
 | 
			
		||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 | 
			
		||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
@@ -333,8 +333,8 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 | 
			
		||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
 | 
			
		||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 | 
			
		||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
@@ -499,8 +499,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 | 
			
		||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 | 
			
		||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
 | 
			
		||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
 | 
			
		||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										32
									
								
								healthcheck/http.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								healthcheck/http.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
package healthcheck
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	log "github.com/sirupsen/logrus"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func HttpRequest(addr string, base string) error {
 | 
			
		||||
	if strings.HasPrefix(addr, ":") {
 | 
			
		||||
		addr = "localhost" + addr
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	url := fmt.Sprintf("http://%s%s/healthcheck", addr, base)
 | 
			
		||||
	log.Info("Checking health of " + url)
 | 
			
		||||
	resp, err := http.Get(url)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
	if resp.StatusCode == 200 {
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	os.Exit(1)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								main.go
									
									
									
									
									
								
							@@ -15,6 +15,7 @@ import (
 | 
			
		||||
	"github.com/alexflint/go-arg"
 | 
			
		||||
	"github.com/amir20/dozzle/analytics"
 | 
			
		||||
	"github.com/amir20/dozzle/docker"
 | 
			
		||||
	"github.com/amir20/dozzle/healthcheck"
 | 
			
		||||
	"github.com/amir20/dozzle/web"
 | 
			
		||||
 | 
			
		||||
	log "github.com/sirupsen/logrus"
 | 
			
		||||
@@ -29,13 +30,16 @@ type args struct {
 | 
			
		||||
	Base                 string              `arg:"env:DOZZLE_BASE" default:"/" help:"sets the base for http router."`
 | 
			
		||||
	Level                string              `arg:"env:DOZZLE_LEVEL" default:"info" help:"set Dozzle log level. Use debug for more logging."`
 | 
			
		||||
	TailSize             int                 `arg:"env:DOZZLE_TAILSIZE" default:"300" help:"update the initial tail size when fetching logs."`
 | 
			
		||||
	Key                  string              `arg:"env:DOZZLE_KEY" help:"set a random key for username and password. This is required for auth."`
 | 
			
		||||
	Username             string              `arg:"env:DOZZLE_USERNAME" help:"sets the username for auth."`
 | 
			
		||||
	Password             string              `arg:"env:DOZZLE_PASSWORD" help:"sets password for auth"`
 | 
			
		||||
	NoAnalytics          bool                `arg:"--no-analytics,env:DOZZLE_NO_ANALYTICS" help:"disables anonymous analytics"`
 | 
			
		||||
	WaitForDockerSeconds int                 `arg:"--wait-for-docker-seconds,env:DOZZLE_WAIT_FOR_DOCKER_SECONDS" help:"wait for docker to be available for at most this many seconds before starting the server."`
 | 
			
		||||
	FilterStrings        []string            `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."`
 | 
			
		||||
	Filter               map[string][]string `arg:"-"`
 | 
			
		||||
	Healthcheck          *HealthcheckCmd     `arg:"subcommand:healthcheck" help:"checks if the server is running."`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type HealthcheckCmd struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (args) Version() string {
 | 
			
		||||
@@ -69,6 +73,12 @@ func main() {
 | 
			
		||||
		DisableLevelTruncation: true,
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if args.Healthcheck != nil {
 | 
			
		||||
		if err := healthcheck.HttpRequest(args.Addr, args.Base); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Infof("Dozzle version %s", version)
 | 
			
		||||
	dockerClient := docker.NewClientWithFilters(args.Filter)
 | 
			
		||||
	for i := 1; ; i++ {
 | 
			
		||||
@@ -88,10 +98,6 @@ func main() {
 | 
			
		||||
		if args.Username == "" || args.Password == "" {
 | 
			
		||||
			log.Fatalf("Username AND password are required for authentication")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if args.Key == "" {
 | 
			
		||||
			log.Fatalf("Key is required for authentication")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	config := web.Config{
 | 
			
		||||
@@ -99,7 +105,6 @@ func main() {
 | 
			
		||||
		Base:     args.Base,
 | 
			
		||||
		Version:  version,
 | 
			
		||||
		TailSize: args.TailSize,
 | 
			
		||||
		Key:      args.Key,
 | 
			
		||||
		Username: args.Username,
 | 
			
		||||
		Password: args.Password,
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										71
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										71
									
								
								package.json
									
									
									
									
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "dozzle",
 | 
			
		||||
  "version": "3.12.6",
 | 
			
		||||
  "version": "4.0.0",
 | 
			
		||||
  "description": "Realtime log viewer for docker containers. ",
 | 
			
		||||
  "homepage": "https://github.com/amir20/dozzle#readme",
 | 
			
		||||
  "bugs": {
 | 
			
		||||
@@ -22,53 +22,54 @@
 | 
			
		||||
    "postinstall": "husky install"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@iconify-json/carbon": "^1.1.4",
 | 
			
		||||
    "@iconify-json/cil": "^1.1.1",
 | 
			
		||||
    "@iconify-json/mdi": "^1.1.12",
 | 
			
		||||
    "@iconify-json/mdi-light": "^1.1.1",
 | 
			
		||||
    "@iconify-json/octicon": "^1.1.7",
 | 
			
		||||
    "@iconify-json/carbon": "^1.1.7",
 | 
			
		||||
    "@iconify-json/cil": "^1.1.2",
 | 
			
		||||
    "@iconify-json/mdi": "^1.1.30",
 | 
			
		||||
    "@iconify-json/mdi-light": "^1.1.2",
 | 
			
		||||
    "@iconify-json/octicon": "^1.1.16",
 | 
			
		||||
    "@oruga-ui/oruga-next": "^0.5.4",
 | 
			
		||||
    "@oruga-ui/theme-bulma": "^0.2.5",
 | 
			
		||||
    "@vitejs/plugin-vue": "^2.3.3",
 | 
			
		||||
    "@vue/compiler-sfc": "^3.2.33",
 | 
			
		||||
    "@vueuse/core": "^8.4.2",
 | 
			
		||||
    "@vueuse/router": "^8.4.2",
 | 
			
		||||
    "@oruga-ui/theme-bulma": "^0.2.6",
 | 
			
		||||
    "@vitejs/plugin-vue": "3.0.3",
 | 
			
		||||
    "@vue/compiler-sfc": "^3.2.37",
 | 
			
		||||
    "@vueuse/core": "^9.1.0",
 | 
			
		||||
    "@vueuse/router": "^9.1.0",
 | 
			
		||||
    "ansi-to-html": "^0.7.2",
 | 
			
		||||
    "bulma": "^0.9.4",
 | 
			
		||||
    "date-fns": "^2.28.0",
 | 
			
		||||
    "fuzzysort": "^1.9.0",
 | 
			
		||||
    "hotkeys-js": "^3.9.3",
 | 
			
		||||
    "date-fns": "^2.29.1",
 | 
			
		||||
    "fuzzysort": "^2.0.1",
 | 
			
		||||
    "hotkeys-js": "^3.9.4",
 | 
			
		||||
    "lodash.debounce": "^4.0.8",
 | 
			
		||||
    "pinia": "^2.0.14",
 | 
			
		||||
    "sass": "^1.51.0",
 | 
			
		||||
    "pinia": "^2.0.18",
 | 
			
		||||
    "sass": "^1.54.4",
 | 
			
		||||
    "semver": "^7.3.7",
 | 
			
		||||
    "splitpanes": "^3.1.1",
 | 
			
		||||
    "typescript": "^4.6.4",
 | 
			
		||||
    "unplugin-auto-import": "^0.7.1",
 | 
			
		||||
    "unplugin-icons": "^0.14.3",
 | 
			
		||||
    "unplugin-vue-components": "^0.19.5",
 | 
			
		||||
    "vite": "^2.9.9",
 | 
			
		||||
    "vue": "^3.2.33",
 | 
			
		||||
    "vue-router": "^4.0.15"
 | 
			
		||||
    "typescript": "^4.7.4",
 | 
			
		||||
    "unplugin-auto-import": "^0.11.1",
 | 
			
		||||
    "unplugin-icons": "^0.14.8",
 | 
			
		||||
    "unplugin-vue-components": "^0.22.4",
 | 
			
		||||
    "vite": "3.0.8",
 | 
			
		||||
    "vue": "^3.2.37",
 | 
			
		||||
    "vue-router": "^4.1.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@pinia/testing": "^0.0.12",
 | 
			
		||||
    "@types/jest": "^27.5.1",
 | 
			
		||||
    "@pinia/testing": "^0.0.13",
 | 
			
		||||
    "@types/jest": "^28.1.7",
 | 
			
		||||
    "@types/lodash.debounce": "^4.0.7",
 | 
			
		||||
    "@types/node": "^17.0.32",
 | 
			
		||||
    "@types/semver": "^7.3.9",
 | 
			
		||||
    "@vue/test-utils": "^2.0.0-rc.21",
 | 
			
		||||
    "c8": "^7.11.2",
 | 
			
		||||
    "@types/node": "^18.7.6",
 | 
			
		||||
    "@types/semver": "^7.3.12",
 | 
			
		||||
    "@vue/test-utils": "^2.0.2",
 | 
			
		||||
    "c8": "^7.12.0",
 | 
			
		||||
    "eventsourcemock": "^2.0.0",
 | 
			
		||||
    "husky": "^8.0.1",
 | 
			
		||||
    "jest-serializer-vue": "^2.0.2",
 | 
			
		||||
    "jsdom": "^19.0.0",
 | 
			
		||||
    "lint-staged": "^12.4.1",
 | 
			
		||||
    "jsdom": "^20.0.0",
 | 
			
		||||
    "lint-staged": "^13.0.3",
 | 
			
		||||
    "npm-run-all": "^4.1.5",
 | 
			
		||||
    "prettier": "^2.6.2",
 | 
			
		||||
    "release-it": "^15.0.0",
 | 
			
		||||
    "ts-node": "^10.6.0",
 | 
			
		||||
    "vitest": "^0.12.4"
 | 
			
		||||
    "prettier": "^2.7.1",
 | 
			
		||||
    "release-it": "^15.3.0",
 | 
			
		||||
    "ts-node": "^10.9.1",
 | 
			
		||||
    "vitest": "^0.22.0",
 | 
			
		||||
    "vue-tsc": "^0.40.1"
 | 
			
		||||
  },
 | 
			
		||||
  "lint-staged": {
 | 
			
		||||
    "*.{js,vue,css}": [
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2481
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2481
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -15,7 +15,8 @@
 | 
			
		||||
    "forceConsistentCasingInFileNames": true,
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@/*": ["assets/*"]
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
    "jsx": "preserve",
 | 
			
		||||
  },
 | 
			
		||||
  "include": ["assets/**/*.ts", "assets/**/*.d.ts", "assets/**/*.vue"],
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -144,7 +144,7 @@ Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
X-Accel-Buffering: no
 | 
			
		||||
 | 
			
		||||
data: INFO Testing logs...
 | 
			
		||||
data: {"m":"INFO Testing logs...","ts":0,"id":4256192898}
 | 
			
		||||
 | 
			
		||||
event: container-stopped
 | 
			
		||||
data: end of stream
 | 
			
		||||
@@ -170,8 +170,8 @@ Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
X-Accel-Buffering: no
 | 
			
		||||
 | 
			
		||||
data: 2020-05-13T18:55:37.772853839Z INFO Testing logs...
 | 
			
		||||
id: 2020-05-13T18:55:37.772853839Z
 | 
			
		||||
data: {"m":"INFO Testing logs...","ts":1589396137,"id":1469707724}
 | 
			
		||||
id: 1589396137
 | 
			
		||||
 | 
			
		||||
event: container-stopped
 | 
			
		||||
data: end of stream
 | 
			
		||||
							
								
								
									
										11
									
								
								web/auth.go
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								web/auth.go
									
									
									
									
									
								
							@@ -1,6 +1,8 @@
 | 
			
		||||
package web
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"crypto/sha256"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
@@ -17,7 +19,7 @@ const sessionName = "session"
 | 
			
		||||
func initializeAuth(h *handler) {
 | 
			
		||||
	secured = false
 | 
			
		||||
	if h.config.Username != "" && h.config.Password != "" {
 | 
			
		||||
		store = sessions.NewCookieStore([]byte(h.config.Key))
 | 
			
		||||
		store = sessions.NewCookieStore(generateSessionStorageKey(h.config.Username, h.config.Password))
 | 
			
		||||
		store.Options.HttpOnly = true
 | 
			
		||||
		store.Options.SameSite = http.SameSiteLaxMode
 | 
			
		||||
		store.Options.MaxAge = 0
 | 
			
		||||
@@ -35,7 +37,7 @@ func authorizationRequired(f http.HandlerFunc) http.Handler {
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	} else {
 | 
			
		||||
		return http.HandlerFunc(f)
 | 
			
		||||
		return f
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -115,3 +117,8 @@ func (h *handler) clearSession(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
 | 
			
		||||
	http.Redirect(w, r, h.config.Base, http.StatusTemporaryRedirect)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func generateSessionStorageKey(username string, password string) []byte {
 | 
			
		||||
	key := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", username, password)))
 | 
			
		||||
	return key[:]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										114
									
								
								web/logs.go
									
									
									
									
									
								
							
							
						
						
									
										114
									
								
								web/logs.go
									
									
									
									
									
								
							@@ -4,6 +4,8 @@ import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"compress/gzip"
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"hash/fnv"
 | 
			
		||||
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
@@ -13,29 +15,12 @@ import (
 | 
			
		||||
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/amir20/dozzle/docker"
 | 
			
		||||
	"github.com/dustin/go-humanize"
 | 
			
		||||
 | 
			
		||||
	log "github.com/sirupsen/logrus"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
 | 
			
		||||
 | 
			
		||||
	from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
 | 
			
		||||
	to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
 | 
			
		||||
	id := r.URL.Query().Get("id")
 | 
			
		||||
 | 
			
		||||
	reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
 | 
			
		||||
	defer reader.Close()
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	io.Copy(w, reader)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	id := r.URL.Query().Get("id")
 | 
			
		||||
	container, err := h.client.FindContainer(id)
 | 
			
		||||
@@ -63,6 +48,64 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	io.Copy(zw, reader)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func logEventIterator(reader *bufio.Reader) func() (docker.LogEvent, error) {
 | 
			
		||||
	return func() (docker.LogEvent, error) {
 | 
			
		||||
		message, readerError := reader.ReadString('\n')
 | 
			
		||||
 | 
			
		||||
		h := fnv.New32a()
 | 
			
		||||
		h.Write([]byte(message))
 | 
			
		||||
 | 
			
		||||
		logEvent := docker.LogEvent{Id: h.Sum32(), Message: message}
 | 
			
		||||
 | 
			
		||||
		if index := strings.IndexAny(message, " "); index != -1 {
 | 
			
		||||
			logId := message[:index]
 | 
			
		||||
			if timestamp, err := time.Parse(time.RFC3339Nano, logId); err == nil {
 | 
			
		||||
				logEvent.Timestamp = timestamp.Unix()
 | 
			
		||||
				message = strings.TrimSuffix(message[index+1:], "\n")
 | 
			
		||||
				logEvent.Message = message
 | 
			
		||||
				if strings.HasPrefix(message, "{") && strings.HasSuffix(message, "}") {
 | 
			
		||||
					var data map[string]interface{}
 | 
			
		||||
					if err := json.Unmarshal([]byte(message), &data); err != nil {
 | 
			
		||||
						log.Errorf("json unmarshal error while streaming %v", err.Error())
 | 
			
		||||
					}
 | 
			
		||||
					logEvent.Message = data
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return logEvent, readerError
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/ld+json; charset=UTF-8")
 | 
			
		||||
 | 
			
		||||
	from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
 | 
			
		||||
	to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
 | 
			
		||||
	id := r.URL.Query().Get("id")
 | 
			
		||||
 | 
			
		||||
	reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
 | 
			
		||||
	defer reader.Close()
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	buffered := bufio.NewReader(reader)
 | 
			
		||||
	eventIterator := logEventIterator(buffered)
 | 
			
		||||
 | 
			
		||||
	for {
 | 
			
		||||
		logEvent, readerError := eventIterator()
 | 
			
		||||
		if readerError != nil {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		if err := json.NewEncoder(w).Encode(logEvent); err != nil {
 | 
			
		||||
			log.Errorf("json encoding error while streaming %v", err.Error())
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	id := r.URL.Query().Get("id")
 | 
			
		||||
	if id == "" {
 | 
			
		||||
@@ -105,17 +148,36 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	}
 | 
			
		||||
	defer reader.Close()
 | 
			
		||||
 | 
			
		||||
	ticker := time.NewTicker(5 * time.Second)
 | 
			
		||||
	defer ticker.Stop()
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		for {
 | 
			
		||||
			select {
 | 
			
		||||
			case <-r.Context().Done():
 | 
			
		||||
				return
 | 
			
		||||
			case <-ticker.C:
 | 
			
		||||
				fmt.Fprintf(w, ":ping \n\n")
 | 
			
		||||
				f.Flush()
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	buffered := bufio.NewReader(reader)
 | 
			
		||||
	var readerError error
 | 
			
		||||
	var message string
 | 
			
		||||
	eventIterator := logEventIterator(buffered)
 | 
			
		||||
 | 
			
		||||
	for {
 | 
			
		||||
		message, readerError = buffered.ReadString('\n')
 | 
			
		||||
		fmt.Fprintf(w, "data: %s\n", message)
 | 
			
		||||
		if index := strings.IndexAny(message, " "); index != -1 {
 | 
			
		||||
			id := message[:index]
 | 
			
		||||
			if _, err := time.Parse(time.RFC3339Nano, id); err == nil {
 | 
			
		||||
				fmt.Fprintf(w, "id: %s\n", id)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
		var logEvent docker.LogEvent
 | 
			
		||||
		logEvent, readerError = eventIterator()
 | 
			
		||||
		if buf, err := json.Marshal(logEvent); err != nil {
 | 
			
		||||
			log.Errorf("json encoding error while streaming %v", err.Error())
 | 
			
		||||
		} else {
 | 
			
		||||
			fmt.Fprintf(w, "data: %s\n", buf)
 | 
			
		||||
		}
 | 
			
		||||
		if logEvent.Timestamp > 0 {
 | 
			
		||||
			fmt.Fprintf(w, "id: %d\n", logEvent.Timestamp)
 | 
			
		||||
		}
 | 
			
		||||
		fmt.Fprintf(w, "\n")
 | 
			
		||||
		f.Flush()
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,6 @@ type Config struct {
 | 
			
		||||
	Addr     string
 | 
			
		||||
	Version  string
 | 
			
		||||
	TailSize int
 | 
			
		||||
	Key      string
 | 
			
		||||
	Username string
 | 
			
		||||
	Password string
 | 
			
		||||
}
 | 
			
		||||
@@ -50,9 +49,9 @@ func createRouter(h *handler) *mux.Router {
 | 
			
		||||
	r := mux.NewRouter()
 | 
			
		||||
	r.Use(cspHeaders)
 | 
			
		||||
	if base != "/" {
 | 
			
		||||
		r.HandleFunc(base, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 | 
			
		||||
		r.HandleFunc(base, func(w http.ResponseWriter, req *http.Request) {
 | 
			
		||||
			http.Redirect(w, req, base+"/", http.StatusMovedPermanently)
 | 
			
		||||
		}))
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
	s := r.PathPrefix(base).Subrouter()
 | 
			
		||||
	s.Handle("/api/logs/stream", authorizationRequired(h.streamLogs))
 | 
			
		||||
@@ -62,6 +61,7 @@ func createRouter(h *handler) *mux.Router {
 | 
			
		||||
	s.HandleFunc("/api/validateCredentials", h.validateCredentials)
 | 
			
		||||
	s.Handle("/logout", authorizationRequired(h.clearSession))
 | 
			
		||||
	s.Handle("/version", authorizationRequired(h.version))
 | 
			
		||||
	s.HandleFunc("/healthcheck", h.healthcheck)
 | 
			
		||||
 | 
			
		||||
	if log.IsLevelEnabled(log.DebugLevel) {
 | 
			
		||||
		s.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)
 | 
			
		||||
@@ -132,3 +132,14 @@ func (h *handler) version(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Add("Content-Type", "text/html")
 | 
			
		||||
	fmt.Fprintf(w, "<pre>%v</pre>", h.config.Version)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) healthcheck(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	log.Trace("Executing healthcheck request")
 | 
			
		||||
 | 
			
		||||
	if ping, err := h.client.Ping(r.Context()); err != nil {
 | 
			
		||||
		log.Error(err)
 | 
			
		||||
		http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
	} else {
 | 
			
		||||
		fmt.Fprintf(w, "OK API Version %v", ping.APIVersion)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -271,7 +271,7 @@ func Test_createRoutes_redirect_with_auth(t *testing.T) {
 | 
			
		||||
	fs := afero.NewMemMapFs()
 | 
			
		||||
	require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.")
 | 
			
		||||
 | 
			
		||||
	handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/foobar", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/foobar", Username: "amir", Password: "password"})
 | 
			
		||||
	req, err := http.NewRequest("GET", "/foobar/", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
@@ -320,7 +320,7 @@ func Test_createRoutes_version(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
func Test_createRoutes_username_password(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
 | 
			
		||||
	req, err := http.NewRequest("GET", "/", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
@@ -329,7 +329,7 @@ func Test_createRoutes_username_password(t *testing.T) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_createRoutes_username_password_invalid(t *testing.T) {
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/logs/stream?id=123", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
@@ -338,7 +338,7 @@ func Test_createRoutes_username_password_invalid(t *testing.T) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_createRoutes_username_password_login_happy(t *testing.T) {
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
 | 
			
		||||
 | 
			
		||||
	body := &bytes.Buffer{}
 | 
			
		||||
	writer := multipart.NewWriter(body)
 | 
			
		||||
@@ -368,7 +368,7 @@ func Test_createRoutes_username_password_login_happy(t *testing.T) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_createRoutes_username_password_login_failed(t *testing.T) {
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
 | 
			
		||||
 | 
			
		||||
	body := &bytes.Buffer{}
 | 
			
		||||
	writer := multipart.NewWriter(body)
 | 
			
		||||
@@ -398,7 +398,7 @@ func Test_createRoutes_username_password_valid_session(t *testing.T) {
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
	mockedClient.On("FindContainer", "123").Return(docker.Container{ID: "123"}, nil)
 | 
			
		||||
	mockedClient.On("ContainerLogs", mock.Anything, "123", 0).Return(ioutil.NopCloser(strings.NewReader("test data")), io.EOF)
 | 
			
		||||
	handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"})
 | 
			
		||||
 | 
			
		||||
	// Get cookie first
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/logs/stream?id=123", nil)
 | 
			
		||||
@@ -422,7 +422,7 @@ func Test_createRoutes_username_password_invalid_session(t *testing.T) {
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
	mockedClient.On("FindContainer", "123").Return(docker.Container{ID: "123"}, nil)
 | 
			
		||||
	mockedClient.On("ContainerLogs", mock.Anything, "123", 0).Return(ioutil.NopCloser(strings.NewReader("test data")), io.EOF)
 | 
			
		||||
	handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
 | 
			
		||||
	handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"})
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/logs/stream?id=123", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	req.AddCookie(&http.Cookie{Name: "session", Value: "baddata"})
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user