├── .air.toml ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── 0_bug_report.yml │ ├── 1_feature_request.yml │ ├── 2_peformance_report.yml │ └── config.yml ├── demo.gif ├── funding.yml ├── renovate.json ├── stale.yml └── workflows │ ├── deploy.yml │ ├── dev.yml │ ├── pages.yml │ ├── pr.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── assets ├── App.vue ├── auto-imports.d.ts ├── components.d.ts ├── components │ ├── Announcements.vue │ ├── ContainerDropdown.vue │ ├── ContainerPopup.vue │ ├── ContainerTable.vue │ ├── ContainerViewer │ │ ├── ContainerActionsToolbar.vue │ │ ├── ContainerHealth.vue │ │ ├── ContainerLog.vue │ │ └── ContainerTitle.vue │ ├── FuzzySearchModal.spec.ts │ ├── FuzzySearchModal.vue │ ├── GroupMenu.vue │ ├── GroupedViewer │ │ └── GroupedLog.vue │ ├── HostList.vue │ ├── HostMenu.vue │ ├── HostViewer │ │ └── HostLog.vue │ ├── InfiniteLoader.vue │ ├── Links.vue │ ├── LogViewer │ │ ├── ComplexLogItem.vue │ │ ├── ContainerEventLogItem.vue │ │ ├── EventSource.spec.ts │ │ ├── EventSource.vue │ │ ├── LogAnalytics.vue │ │ ├── LogDate.vue │ │ ├── LogDetails.vue │ │ ├── LogItem.vue │ │ ├── LogLevel.vue │ │ ├── LogList.vue │ │ ├── LogMessageActions.vue │ │ ├── LogStd.vue │ │ ├── LogViewer.vue │ │ ├── MultiContainerActionToolbar.vue │ │ ├── MultiContainerStat.vue │ │ ├── RandomColorTag.vue │ │ ├── SQLTable.vue │ │ ├── SimpleLogItem.vue │ │ ├── SkippedEntriesLogItem.vue │ │ ├── StatMonitor.vue │ │ ├── StatSparkline.vue │ │ ├── ViewerWithSource.vue │ │ ├── ZigZag.vue │ │ └── __snapshots__ │ │ │ └── EventSource.spec.ts.snap │ ├── MultiContainerViewer │ │ └── MultiContainerLog.vue │ ├── PageWithLinks.vue │ ├── Popup.vue │ ├── ScrollProgress.vue │ ├── ScrollableView.vue │ ├── Search.vue │ ├── ServiceViewer │ │ └── ServiceLog.vue │ ├── SideMenu.vue │ ├── SidePanel.vue │ ├── StackViewer │ │ └── StackLog.vue │ ├── SwarmMenu.vue │ ├── Terminal.vue │ └── common │ │ ├── Carousel.vue │ │ ├── CarouselItem.vue │ │ ├── DateTime.vue │ │ ├── Dropdown.vue │ │ ├── DropdownMenu.vue │ │ ├── HostIcon.vue │ │ ├── IndeterminateBar.vue │ │ ├── KeyShortcut.vue │ │ ├── LabeledInput.vue │ │ ├── MobileMenu.vue │ │ ├── RelativeTime.vue │ │ ├── SideDrawer.vue │ │ ├── SlideTransition.vue │ │ ├── Tag.vue │ │ ├── TimedButton.vue │ │ ├── ToastModal.vue │ │ └── Toggle.vue ├── composable │ ├── containerActions.ts │ ├── drawer.ts │ ├── duckdb.ts │ ├── eventStreams.ts │ ├── logContext.ts │ ├── media.ts │ ├── popup.ts │ ├── profileStorage.ts │ ├── scrollContext.ts │ ├── search.ts │ ├── storage.ts │ ├── title.ts │ ├── toast.ts │ └── visible.ts ├── layouts │ ├── default.vue │ └── splash.vue ├── logo.svg ├── main.css ├── main.ts ├── models │ ├── Container.ts │ ├── LogEntry.ts │ └── Stack.ts ├── modules │ ├── i18n.ts │ ├── pinia.ts │ └── router.ts ├── pages │ ├── [...all].vue │ ├── container │ │ └── [id].vue │ ├── group │ │ └── [name].vue │ ├── host │ │ └── [id].vue │ ├── index.vue │ ├── login.vue │ ├── merged │ │ └── [ids].vue │ ├── service │ │ └── [name].vue │ ├── settings.vue │ ├── show.vue │ └── stack │ │ └── [name].vue ├── shims-vue.d.ts ├── stores │ ├── announcements.ts │ ├── config.ts │ ├── container.ts │ ├── hosts.ts │ ├── pinned.ts │ ├── settings.ts │ └── swarm.ts ├── typed-router.d.ts ├── types │ ├── Container.d.ts │ └── Point.d.ts └── utils │ └── index.ts ├── docker-compose.yml ├── docs ├── .gitignore ├── .vitepress │ ├── .gitignore │ ├── activity.data.ts │ ├── config.ts │ └── theme │ │ ├── components │ │ ├── Banner.vue │ │ ├── BuyMeCoffee.vue │ │ ├── Counter.vue │ │ ├── HeroVideo.vue │ │ ├── Stats.vue │ │ └── Supported.vue │ │ ├── index.ts │ │ ├── media │ │ ├── dozzle-dark.mp4 │ │ ├── dozzle-light.mp4 │ │ ├── dozzle-ui-actions.png │ │ ├── poster-dark.png │ │ └── poster-light.png │ │ └── style.css ├── components.d.ts ├── guide │ ├── actions.md │ ├── agent.md │ ├── analytics.md │ ├── authentication.md │ ├── changing-base.md │ ├── container-groups.md │ ├── container-names.md │ ├── debugging.md │ ├── faq.md │ ├── filters.md │ ├── getting-started.md │ ├── healthcheck.md │ ├── hostname.md │ ├── k8s.md │ ├── log-files-on-disk.md │ ├── podman.md │ ├── remote-hosts.md │ ├── shell.md │ ├── sql-engine.md │ ├── supported-env-vars.md │ ├── swarm-mode.md │ └── what-is-dozzle.md ├── index.md ├── package.json ├── pnpm-lock.yaml ├── public │ ├── _headers │ ├── _redirects │ ├── favicon.ico │ └── logo.svg ├── support.md ├── team.md └── vite.config.ts ├── e2e ├── agent.ts ├── custom.spec.ts ├── data │ └── users.yml ├── default.spec.ts ├── remote.spec.ts ├── simple.spec.ts ├── visual.spec.ts └── visual.spec.ts-snapshots │ ├── dark-homepage-1-Mobile-Chrome-linux.png │ ├── dark-homepage-1-chromium-linux.png │ ├── default-homepage-1-Mobile-Chrome-linux.png │ └── default-homepage-1-chromium-linux.png ├── examples ├── README.md ├── docker.agents-with-certs.yml ├── docker.agents.yml ├── docker.swarm.auth.yml ├── docker.swarm.yml ├── ingress.yml ├── k8s.dozzle.yml ├── setup-swarm.fish └── users.yml ├── go.mod ├── go.sum ├── internal ├── agent │ ├── client.go │ ├── client_test.go │ ├── pb │ │ ├── rpc.pb.go │ │ ├── rpc_grpc.pb.go │ │ └── types.pb.go │ └── server.go ├── analytics │ └── http_beacon.go ├── auth │ ├── proxy.go │ ├── simple.go │ └── users.go ├── cache │ └── expire.go ├── container │ ├── client.go │ ├── container_store.go │ ├── container_store_test.go │ ├── event_generator.go │ ├── event_generator_test.go │ ├── host.go │ ├── level_guesser.go │ ├── level_guesser_test.go │ ├── logfmt.go │ ├── logfmt_test.go │ ├── stripansi.go │ ├── types.go │ └── types_test.go ├── docker │ ├── calculation.go │ ├── calculation_test.go │ ├── client.go │ ├── client_test.go │ ├── log_reader.go │ ├── stats_collector.go │ └── stats_collector_test.go ├── healthcheck │ ├── http.go │ ├── http_test.go │ └── rpc.go ├── k8s │ ├── client.go │ ├── log_reader.go │ └── stats_collector.go ├── profile │ └── disk.go ├── releases │ └── github.go ├── support │ ├── cli │ │ ├── agent_command.go │ │ ├── agent_test_command.go │ │ ├── analytics.go │ │ ├── args.go │ │ ├── certs.go │ │ ├── clients.go │ │ ├── generate_command.go │ │ ├── health_command.go │ │ ├── logger.go │ │ └── valid_env.go │ ├── container │ │ ├── agent_service.go │ │ ├── client_service.go │ │ └── container_service.go │ ├── docker │ │ ├── docker_service.go │ │ ├── multi_host_service.go │ │ ├── retriable_client_manager.go │ │ └── swarm_client_manager.go │ ├── k8s │ │ ├── k8s_cluster_service.go │ │ └── k8s_service.go │ └── web │ │ ├── escape.go │ │ ├── regex.go │ │ ├── search.go │ │ ├── sse.go │ │ └── url.go ├── utils │ ├── ring_buffer.go │ ├── ring_buffer_test.go │ └── time.go └── web │ ├── __snapshots__ │ └── web.snapshot │ ├── actions.go │ ├── actions_test.go │ ├── auth.go │ ├── auth_proxy_test.go │ ├── auth_simple_test.go │ ├── auth_test.go │ ├── brotli.go │ ├── csp.go │ ├── debug.go │ ├── download.go │ ├── download_test.go │ ├── events.go │ ├── events_test.go │ ├── healthcheck.go │ ├── index.go │ ├── logs.go │ ├── logs_test.go │ ├── profile.go │ ├── releases.go │ ├── routes.go │ ├── routes_test.go │ ├── terminal.go │ └── version.go ├── locales ├── da.yml ├── de.yml ├── en.yml ├── es.yml ├── fr.yml ├── id.yml ├── it.yml ├── nl.yml ├── pl.yml ├── pr.yml ├── pt.yml ├── ru.yml ├── tr.yml ├── zh-tw.yml └── zh.yml ├── main.go ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── protos ├── rpc.proto └── types.proto ├── public ├── apple-touch-icon.png ├── favicon.ico ├── favicon.png ├── index.html └── manifest.webmanifest ├── tsconfig.json ├── types └── types.go └── vite.config.ts /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/dozzle" 8 | cmd = "go build -race -o ./tmp/dozzle ." 9 | delay = 1000 10 | exclude_dir = [ 11 | "assets", 12 | "tmp", 13 | "data", 14 | "node_modules", 15 | "docs", 16 | "test-results", 17 | "dist", 18 | "e2e", 19 | "examples", 20 | ] 21 | exclude_file = [] 22 | exclude_regex = ["_test.go"] 23 | exclude_unchanged = false 24 | follow_symlink = false 25 | full_bin = "" 26 | include_dir = [] 27 | include_ext = ["go", "tpl", "tmpl", "html"] 28 | include_file = [] 29 | kill_delay = "2s" 30 | log = "build-errors.log" 31 | poll = false 32 | poll_interval = 0 33 | post_cmd = [] 34 | pre_cmd = [] 35 | rerun = false 36 | rerun_delay = 500 37 | send_interrupt = true 38 | stop_on_error = true 39 | 40 | [color] 41 | app = "" 42 | build = "yellow" 43 | main = "magenta" 44 | runner = "green" 45 | watcher = "cyan" 46 | 47 | [log] 48 | main_only = false 49 | silent = false 50 | time = false 51 | 52 | [misc] 53 | clean_on_exit = false 54 | 55 | [screen] 56 | clear_on_rebuild = false 57 | keep_scroll = true 58 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | .idea 4 | .github 5 | dist 6 | .git 7 | e2e 8 | docs 9 | internal/agent/pb/ 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 120 11 | 12 | [*.go] 13 | indent_style = tab 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | 19 | [package.json] 20 | indent_size = 1 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.snapshot binary 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | labels: ["enhancement"] 3 | description: | 4 | Use this template if you have a feature request or an idea for a new feature. 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Describe the feature you would like to see 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Describe how you would like to see this feature implemented 14 | validations: 15 | required: false 16 | - type: textarea 17 | attributes: 18 | label: Describe any alternatives you've considered 19 | validations: 20 | required: false 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2_peformance_report.yml: -------------------------------------------------------------------------------- 1 | name: 🚤 Performance Issue 2 | labels: ["performance"] 3 | description: | 4 | Use this template if you are seeing performance issues such as slow loading times or high CPU usage. 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Check for existing issues 9 | description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. 10 | options: 11 | - label: Completed 12 | required: true 13 | - type: dropdown 14 | attributes: 15 | label: How is Dozzle deployed? 16 | description: Select the components that you are using. 17 | options: 18 | - Standalone Deployment 19 | - Agents 20 | - Remote Hosts 21 | - Swarm Mode 22 | validations: 23 | required: true 24 | - type: input 25 | attributes: 26 | label: Dozzle version 27 | description: Can be found in logs or settings page. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: ✅ Command used to run Dozzle 33 | description: Please provide the command used to run Dozzle. Provide docker-compose.yml if applicable. 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Describe the performance issue you are seeing 39 | validations: 40 | required: true 41 | - type: textarea 42 | attributes: 43 | label: How many containers, hosts, agents, etc. are you using? 44 | description: Any details to reproduce the issue would be helpful. If related to volume of logs, please provide a guesstimate of how many logs are being generated. 45 | validations: 46 | required: true 47 | - type: textarea 48 | attributes: 49 | label: Screenshots, videos, or logs 50 | description: Drag issues into the text input below 51 | validations: 52 | required: false 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💁🏼 Common Issues 4 | url: https://dozzle.dev/guide/faq 5 | about: Check the FAQ for common issues. 6 | -------------------------------------------------------------------------------- /.github/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/.github/demo.gif -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: amirraminfar 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"], 4 | "rangeStrategy": "bump", 5 | "ignoreDeps": [] 6 | } 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 20 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 3 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - keep 10 | # Label to use when marking an issue as stale 11 | staleLabel: wontfix 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | name: Push container 9 | jobs: 10 | buildx: 11 | name: Push branches and PRs 12 | runs-on: ubuntu-latest 13 | if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }} 14 | steps: 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 17 | - name: Login to DockerHub 18 | uses: docker/login-action@v3.4.0 19 | with: 20 | username: ${{ secrets.DOCKER_USERNAME }} 21 | password: ${{ secrets.DOCKER_PASSWORD }} 22 | - name: Log in to the Container registry 23 | uses: docker/login-action@v3.4.0 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Docker meta 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: | 35 | amir20/dozzle 36 | ghcr.io/amir20/dozzle 37 | - name: Writing certs to file 38 | run: | 39 | echo "${{ secrets.TTL_KEY }}" > shared_key.pem 40 | echo "${{ secrets.TTL_CERT }}" > shared_cert.pem 41 | - name: Build and push 42 | uses: docker/build-push-action@v6.18.0 43 | with: 44 | context: . 45 | push: true 46 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 47 | tags: ${{ steps.meta.outputs.tags }} 48 | build-args: TAG=${{ steps.meta.outputs.version }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | cache-from: type=gha 51 | cache-to: type=gha,mode=max 52 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 16 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 17 | concurrency: 18 | group: pages 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | # Build job 23 | build: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 30 | - uses: pnpm/action-setup@v2 31 | - name: Setup Node 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: latest 35 | cache: pnpm # or pnpm / yarn 36 | - name: Setup Pages 37 | uses: actions/configure-pages@v5 38 | - name: Install dependencies 39 | run: pnpm install 40 | - name: Build with VitePress 41 | run: | 42 | pnpm docs:build 43 | touch docs/.vitepress/dist/.nojekyll 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | path: docs/.vitepress/dist 48 | 49 | # Deployment job 50 | deploy: 51 | environment: 52 | name: github-pages 53 | url: ${{ steps.deployment.outputs.page_url }} 54 | needs: build 55 | runs-on: ubuntu-latest 56 | name: Deploy 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4 61 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | main: 15 | name: Validate PR title 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v5 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | certs 2 | /data 3 | dist 4 | node_modules 5 | .cache 6 | static 7 | dozzle 8 | coverage 9 | .pnpm-debug.log 10 | coverage.out 11 | .netlify 12 | /test-results/ 13 | /playwright-report/ 14 | /playwright/.cache/ 15 | *.pem 16 | *.csr 17 | tmp 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shared-workspace-lockfile=false 2 | include-workspace-root=true 3 | ignore-workspace-root-check=true 4 | 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | auto-imports.d.ts 2 | components.d.ts 3 | docs/.vitepress/cache 4 | docs/.vitepress/dist 5 | dist 6 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | plugins: ["prettier-plugin-tailwindcss"], 4 | }; 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build assets 2 | FROM --platform=$BUILDPLATFORM node:23.11.1-alpine AS node 3 | 4 | RUN corepack enable 5 | 6 | WORKDIR /build 7 | 8 | # Install dependencies from lock file 9 | COPY pnpm-*.yaml ./ 10 | RUN pnpm fetch --ignore-scripts --no-optional 11 | 12 | # Copy package.json and install dependencies 13 | COPY package.json ./ 14 | RUN pnpm install --offline --ignore-scripts --no-optional 15 | 16 | # Copy assets and translations to build 17 | COPY .* *.config.ts *.config.js *.config.cjs ./ 18 | COPY assets ./assets 19 | COPY locales ./locales 20 | COPY public ./public 21 | 22 | # Build assets 23 | RUN pnpm build 24 | 25 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder 26 | 27 | # install gRPC dependencies 28 | RUN apk add --no-cache ca-certificates protoc protobuf-dev\ 29 | && mkdir /dozzle \ 30 | && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ 31 | && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 32 | 33 | WORKDIR /dozzle 34 | 35 | # Copy go mod files 36 | COPY go.* ./ 37 | RUN go mod download 38 | 39 | # Copy all other files 40 | COPY internal ./internal 41 | COPY types ./types 42 | COPY main.go ./ 43 | COPY protos ./protos 44 | COPY shared_key.pem shared_cert.pem ./ 45 | 46 | # Copy assets built with node 47 | COPY --from=node /build/dist ./dist 48 | 49 | # Args 50 | ARG TAG=dev 51 | ARG TARGETOS TARGETARCH 52 | 53 | # Generate protos 54 | RUN go generate 55 | 56 | # Build binary 57 | RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle 58 | 59 | RUN mkdir /data 60 | 61 | FROM scratch 62 | 63 | COPY --from=builder /data /data 64 | COPY --from=builder /tmp /tmp 65 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 66 | COPY --from=builder /dozzle/dozzle /dozzle 67 | 68 | EXPOSE 8080 69 | 70 | ENTRYPOINT ["/dozzle"] 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Amir Raminfar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROTO_DIR := protos 2 | GEN_DIR := internal/agent/pb 3 | PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto) 4 | GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES)) 5 | 6 | .PHONY: clean 7 | clean: 8 | @rm -rf dist 9 | @go clean -i 10 | @rm -f shared_key.pem shared_cert.pem 11 | @rm -f $(GEN_DIR)/*.pb.go 12 | 13 | .PHONY: dist 14 | dist: 15 | @pnpm build 16 | 17 | .PHONY: fake_assets 18 | fake_assets: 19 | @echo 'Skipping asset build' 20 | @mkdir -p dist 21 | @echo "assets build was skipped" > dist/index.html 22 | 23 | .PHONY: test 24 | test: fake_assets generate 25 | go test -cover -race -count 1 -timeout 40s ./... 26 | 27 | .PHONY: build 28 | build: dist generate 29 | CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local" 30 | 31 | .PHONY: docker 32 | docker: shared_key.pem shared_cert.pem 33 | @docker build --build-arg TAG=local -t amir20/dozzle . 34 | 35 | generate: shared_key.pem shared_cert.pem $(GEN_FILES) 36 | 37 | .PHONY: dev 38 | dev: generate fake_assets 39 | pnpm dev 40 | 41 | .PHONY: int 42 | int: 43 | docker compose up --build --force-recreate --exit-code-from playwright 44 | 45 | shared_key.pem: 46 | @openssl genpkey -algorithm Ed25519 -out shared_key.pem 47 | 48 | shared_cert.pem: shared_key.pem 49 | @openssl req -new -key shared_key.pem -out shared_request.csr -subj "/C=US/ST=California/L=San Francisco/O=Dozzle" 50 | @openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 365 51 | @rm shared_request.csr 52 | 53 | $(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto 54 | @go generate 55 | 56 | .PHONY: push 57 | push: docker 58 | @docker tag amir20/dozzle:latest amir20/dozzle:local-test 59 | @docker push amir20/dozzle:local-test 60 | 61 | tools: 62 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 63 | go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 64 | go install github.com/air-verse/air@latest 65 | 66 | run: docker 67 | docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest 68 | 69 | preview: build 70 | pnpm preview 71 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Dozzle is checked daily with https://docs.docker.com/scout/ and its dependencies are updated regularly. If you find a security bug, please update to the latest. For vulnerability on latest images please file an issue on Github. 6 | -------------------------------------------------------------------------------- /assets/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | 53 | -------------------------------------------------------------------------------- /assets/components/ContainerDropdown.vue: -------------------------------------------------------------------------------- 1 | 19 | 25 | -------------------------------------------------------------------------------- /assets/components/ContainerPopup.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 39 | -------------------------------------------------------------------------------- /assets/components/ContainerViewer/ContainerHealth.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /assets/components/ContainerViewer/ContainerLog.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 57 | -------------------------------------------------------------------------------- /assets/components/GroupMenu.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 29 | 30 | -------------------------------------------------------------------------------- /assets/components/GroupedViewer/GroupedLog.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 48 | -------------------------------------------------------------------------------- /assets/components/HostViewer/HostLog.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 44 | -------------------------------------------------------------------------------- /assets/components/InfiniteLoader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 32 | -------------------------------------------------------------------------------- /assets/components/Links.vue: -------------------------------------------------------------------------------- 1 | 40 | 49 | -------------------------------------------------------------------------------- /assets/components/LogViewer/ComplexLogItem.vue: -------------------------------------------------------------------------------- 1 | 15 | 38 | 39 | 45 | -------------------------------------------------------------------------------- /assets/components/LogViewer/ContainerEventLogItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /assets/components/LogViewer/LogDate.vue: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /assets/components/LogViewer/LogItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 37 | -------------------------------------------------------------------------------- /assets/components/LogViewer/LogLevel.vue: -------------------------------------------------------------------------------- 1 | 4 | 12 | 13 | 37 | 57 | -------------------------------------------------------------------------------- /assets/components/LogViewer/LogMessageActions.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 45 | -------------------------------------------------------------------------------- /assets/components/LogViewer/LogStd.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /assets/components/LogViewer/LogViewer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 42 | 43 | -------------------------------------------------------------------------------- /assets/components/LogViewer/RandomColorTag.vue: -------------------------------------------------------------------------------- 1 | 9 | 33 | 41 | 42 | 47 | -------------------------------------------------------------------------------- /assets/components/LogViewer/SQLTable.vue: -------------------------------------------------------------------------------- 1 | 31 | 41 | -------------------------------------------------------------------------------- /assets/components/LogViewer/SimpleLogItem.vue: -------------------------------------------------------------------------------- 1 | 15 | 33 | -------------------------------------------------------------------------------- /assets/components/LogViewer/SkippedEntriesLogItem.vue: -------------------------------------------------------------------------------- 1 | 11 | 18 | -------------------------------------------------------------------------------- /assets/components/LogViewer/StatMonitor.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | -------------------------------------------------------------------------------- /assets/components/LogViewer/StatSparkline.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 45 | -------------------------------------------------------------------------------- /assets/components/LogViewer/ViewerWithSource.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | -------------------------------------------------------------------------------- /assets/components/LogViewer/ZigZag.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /assets/components/MultiContainerViewer/MultiContainerLog.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 38 | -------------------------------------------------------------------------------- /assets/components/PageWithLinks.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /assets/components/Popup.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 47 | 48 | 59 | -------------------------------------------------------------------------------- /assets/components/Search.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 56 | 57 | 69 | -------------------------------------------------------------------------------- /assets/components/ServiceViewer/ServiceLog.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 42 | -------------------------------------------------------------------------------- /assets/components/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 39 | 40 | -------------------------------------------------------------------------------- /assets/components/SidePanel.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /assets/components/StackViewer/StackLog.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 45 | -------------------------------------------------------------------------------- /assets/components/common/CarouselItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /assets/components/common/DateTime.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 40 | -------------------------------------------------------------------------------- /assets/components/common/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /assets/components/common/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 45 | -------------------------------------------------------------------------------- /assets/components/common/HostIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 12 | -------------------------------------------------------------------------------- /assets/components/common/IndeterminateBar.vue: -------------------------------------------------------------------------------- 1 | 4 | 18 | 19 | 34 | -------------------------------------------------------------------------------- /assets/components/common/KeyShortcut.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | 20 | -------------------------------------------------------------------------------- /assets/components/common/LabeledInput.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/components/common/MobileMenu.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 39 | 61 | -------------------------------------------------------------------------------- /assets/components/common/RelativeTime.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /assets/components/common/SideDrawer.vue: -------------------------------------------------------------------------------- 1 | 22 | 40 | 59 | -------------------------------------------------------------------------------- /assets/components/common/SlideTransition.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | 43 | -------------------------------------------------------------------------------- /assets/components/common/Tag.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /assets/components/common/TimedButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /assets/components/common/ToastModal.vue: -------------------------------------------------------------------------------- 1 | 40 | 43 | -------------------------------------------------------------------------------- /assets/components/common/Toggle.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /assets/composable/containerActions.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "@/models/Container"; 2 | 3 | type ContainerActions = "start" | "stop" | "restart"; 4 | export const useContainerActions = (container: Ref) => { 5 | const { showToast } = useToast(); 6 | 7 | const actionStates = reactive({ 8 | stop: false, 9 | restart: false, 10 | start: false, 11 | }); 12 | 13 | async function actionHandler(action: ContainerActions) { 14 | const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`; 15 | 16 | const errors = { 17 | 404: "container not found", 18 | 500: "unable to complete action", 19 | 400: "invalid action", 20 | } as Record; 21 | 22 | const defaultError = "something went wrong"; 23 | const toastTitle = "Action Failed"; 24 | 25 | actionStates[action] = true; 26 | 27 | try { 28 | const response = await fetch(withBase(actionUrl), { method: "POST" }); 29 | if (!response.ok) { 30 | const message = errors[response.status] ?? defaultError; 31 | showToast({ type: "error", message, title: toastTitle }); 32 | } 33 | } catch (error) { 34 | showToast({ type: "error", message: defaultError, title: toastTitle }); 35 | } 36 | 37 | actionStates[action] = false; 38 | } 39 | 40 | return { 41 | actionStates, 42 | start: () => actionHandler("start"), 43 | stop: () => actionHandler("stop"), 44 | restart: () => actionHandler("restart"), 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /assets/composable/drawer.ts: -------------------------------------------------------------------------------- 1 | import SideDrawer from "@/components/common/SideDrawer.vue"; 2 | import { Component } from "vue"; 3 | 4 | export type DrawerWidth = "md" | "xl" | "lg"; 5 | 6 | export const drawerContext = Symbol("drawer") as InjectionKey< 7 | (c: Component, p: Record, s?: DrawerWidth) => void 8 | >; 9 | 10 | export const createDrawer = (drawer: Ref>) => { 11 | const component = shallowRef(null); 12 | const properties = shallowRef>({}); 13 | const width = ref("md"); 14 | const showDrawer = (c: Component, p: Record, w: DrawerWidth = "md") => { 15 | component.value = c; 16 | properties.value = p; 17 | width.value = w; 18 | drawer.value?.open(); 19 | }; 20 | 21 | provide(drawerContext, showDrawer); 22 | 23 | return { component, properties, showDrawer, width }; 24 | }; 25 | 26 | export const useDrawer = () => 27 | inject(drawerContext, () => { 28 | console.error("No drawer context provided"); 29 | }); 30 | -------------------------------------------------------------------------------- /assets/composable/duckdb.ts: -------------------------------------------------------------------------------- 1 | import * as duckdb from "@duckdb/duckdb-wasm"; 2 | const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); 3 | 4 | export async function useDuckDB() { 5 | let cleanup: (() => void) | undefined; 6 | onUnmounted(() => cleanup?.()); 7 | 8 | const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); 9 | const worker_url = URL.createObjectURL( 10 | new Blob([`importScripts("${bundle.mainWorker!}");`], { type: "text/javascript" }), 11 | ); 12 | 13 | // Instantiate the asynchronus version of DuckDB-Wasm 14 | const worker = new Worker(worker_url); 15 | const logger = new duckdb.ConsoleLogger(); 16 | const db = new duckdb.AsyncDuckDB(logger, worker); 17 | 18 | await db.instantiate(bundle.mainModule, bundle.pthreadWorker); 19 | URL.revokeObjectURL(worker_url); 20 | const conn = await db.connect(); 21 | 22 | cleanup = async () => { 23 | console.log("Cleaning up DuckDB"); 24 | await conn.close(); 25 | await db.terminate(); 26 | worker.terminate(); 27 | }; 28 | 29 | return { db, conn }; 30 | } 31 | -------------------------------------------------------------------------------- /assets/composable/logContext.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "@/models/Container"; 2 | import { Level } from "@/models/LogEntry"; 3 | 4 | type LogContext = { 5 | streamConfig: { stdout: boolean; stderr: boolean }; 6 | containers: Container[]; 7 | loadingMore: boolean; 8 | hasComplexLogs: boolean; 9 | levels: Set; 10 | showContainerName: boolean; 11 | showHostname: boolean; 12 | }; 13 | 14 | export const allLevels: Level[] = ["info", "debug", "warn", "error", "fatal", "trace", "unknown"]; 15 | 16 | // export for testing 17 | export const loggingContextKey = Symbol("loggingContext") as InjectionKey; 18 | const searchParams = new URLSearchParams(window.location.search); 19 | const stdout = searchParams.has("stdout") ? searchParams.get("stdout") === "true" : true; 20 | const stderr = searchParams.has("stderr") ? searchParams.get("stderr") === "true" : true; 21 | 22 | export const provideLoggingContext = ( 23 | containers: Ref, 24 | { showContainerName = false, showHostname = false } = {}, 25 | ) => { 26 | provide( 27 | loggingContextKey, 28 | reactive({ 29 | streamConfig: { stdout, stderr }, 30 | containers, 31 | loadingMore: false, 32 | hasComplexLogs: false, 33 | levels: new Set(allLevels), 34 | showContainerName, 35 | showHostname, 36 | }), 37 | ); 38 | }; 39 | 40 | export const useLoggingContext = () => { 41 | const context = inject( 42 | loggingContextKey, 43 | reactive({ 44 | streamConfig: { stdout: true, stderr: true }, 45 | containers: [], 46 | loadingMore: false, 47 | hasComplexLogs: false, 48 | levels: new Set(allLevels), 49 | showContainerName: false, 50 | showHostname: false, 51 | }), 52 | ); 53 | 54 | return toRefs(context); 55 | }; 56 | -------------------------------------------------------------------------------- /assets/composable/media.ts: -------------------------------------------------------------------------------- 1 | export const isMobile = useMediaQuery("(max-width: 770px)"); 2 | -------------------------------------------------------------------------------- /assets/composable/popup.ts: -------------------------------------------------------------------------------- 1 | const show = ref(false); 2 | const debouncedShow = debouncedRef(show, 1000); 3 | 4 | const delayedShow = computed({ 5 | set(newVal: boolean) { 6 | show.value = newVal; 7 | }, 8 | get() { 9 | return debouncedShow.value; 10 | }, 11 | }); 12 | 13 | export const globalShowPopup = () => delayedShow; 14 | -------------------------------------------------------------------------------- /assets/composable/scrollContext.ts: -------------------------------------------------------------------------------- 1 | type ScrollContext = { 2 | loading: boolean; 3 | paused: boolean; 4 | progress: number; 5 | currentDate: Date; 6 | }; 7 | 8 | // export for testing 9 | export const scrollContextKey = Symbol("scrollContext") as InjectionKey; 10 | 11 | export const provideScrollContext = () => { 12 | const context = defauleValue(); 13 | provide(scrollContextKey, context); 14 | return context; 15 | }; 16 | 17 | export const useScrollContext = () => { 18 | const defaultValue = defauleValue(); 19 | const context = inject(scrollContextKey, defaultValue); 20 | return toRefs(context); 21 | }; 22 | 23 | function defauleValue() { 24 | return reactive({ 25 | loading: false, 26 | paused: false, 27 | progress: 1, 28 | currentDate: new Date(), 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /assets/composable/search.ts: -------------------------------------------------------------------------------- 1 | const searchQueryFilter = ref(""); 2 | const debouncedSearchFilter = refDebounced(searchQueryFilter); 3 | const showSearch = ref(false); 4 | 5 | const searchParams = new URLSearchParams(window.location.search); 6 | if (searchParams.get("search") !== null && searchParams.get("search") !== "") { 7 | searchQueryFilter.value = searchParams.get("search") || ""; 8 | showSearch.value = true; 9 | } 10 | function resetSearch() { 11 | searchQueryFilter.value = ""; 12 | showSearch.value = false; 13 | } 14 | 15 | const isSearching = computed(() => showSearch.value && debouncedSearchFilter.value !== ""); 16 | 17 | const isValidQuery = computed(() => { 18 | try { 19 | new RegExp(searchQueryFilter.value); 20 | return true; 21 | } catch (e) { 22 | return false; 23 | } 24 | }); 25 | 26 | export function useSearchFilter() { 27 | return { 28 | searchQueryFilter, 29 | isValidQuery, 30 | debouncedSearchFilter, 31 | showSearch, 32 | resetSearch, 33 | isSearching, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /assets/composable/storage.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "@/models/Container"; 2 | 3 | const DOZZLE_HOST = "DOZZLE_HOST"; 4 | export const sessionHost = useSessionStorage(DOZZLE_HOST, null); 5 | 6 | if (config.hosts.length === 1 && !sessionHost.value) { 7 | sessionHost.value = config.hosts[0].id; 8 | } 9 | 10 | const storage = useProfileStorage("visibleKeys", new Map>(), { 11 | from(transformed: [string, [string[], boolean][]][]) { 12 | return new Map(transformed.map(([key, value]) => [key, new Map(value)])); 13 | }, 14 | to(value: Map>) { 15 | const outer = Array.from(value.entries()); 16 | const inner = outer.map(([key, value]) => [key, Array.from(value.entries())]); 17 | return inner; 18 | }, 19 | }); 20 | export function persistentVisibleKeysForContainer(container: Ref): Ref> { 21 | // Computed property to only store to storage when the value changes 22 | return computed({ 23 | get: () => storage.value.get(container.value.storageKey) || new Map(), 24 | set: (value: Map) => storage.value.set(container.value.storageKey, value), 25 | }); 26 | } 27 | 28 | export const pinnedContainers = useProfileStorage("pinned", new Set()); 29 | -------------------------------------------------------------------------------- /assets/composable/title.ts: -------------------------------------------------------------------------------- 1 | const { hostname } = config; 2 | let subtitle = $ref(""); 3 | const title = $computed(() => (subtitle ? `${subtitle} - ` : "") + "Dozzle" + (hostname ? ` @ ${hostname}` : "")); 4 | 5 | useTitle($$(title)); 6 | 7 | export function setTitle(t: string) { 8 | subtitle = t; 9 | } 10 | -------------------------------------------------------------------------------- /assets/composable/toast.ts: -------------------------------------------------------------------------------- 1 | type Toast = { 2 | id: string; 3 | createdAt: Date; 4 | title?: string; 5 | message: string; 6 | type: "success" | "error" | "warning" | "info"; 7 | action?: { 8 | label: string; 9 | handler: () => void; 10 | }; 11 | }; 12 | 13 | type ToastOptions = { 14 | expire?: number; 15 | once?: boolean; 16 | timed?: number; 17 | }; 18 | 19 | const toasts = ref< 20 | { 21 | toast: Toast; 22 | options: ToastOptions; 23 | }[] 24 | >([]); 25 | 26 | const showToast = ( 27 | toast: Omit & { id?: string }, 28 | { expire = -1, once = false, timed }: ToastOptions = { expire: -1, once: false }, 29 | ) => { 30 | if (once && !toast.id) { 31 | throw new Error("Toast id is required when once is true"); 32 | } 33 | if (once && toasts.value.some((t) => t.toast.id === toast.id)) { 34 | return; 35 | } 36 | 37 | const toastWithId = { 38 | id: Date.now().toString(), 39 | ...toast, 40 | createdAt: new Date(), 41 | }; 42 | toasts.value.push({ 43 | toast: toastWithId, 44 | options: { expire, once, timed }, 45 | }); 46 | 47 | if (expire > 0) { 48 | setTimeout(() => { 49 | removeToast(toastWithId.id); 50 | }, expire); 51 | } 52 | }; 53 | 54 | const removeToast = (id: Toast["id"]) => { 55 | toasts.value = toasts.value.filter((instance) => instance.toast.id !== id); 56 | }; 57 | 58 | export const useToast = () => { 59 | return { 60 | toasts, 61 | showToast, 62 | removeToast, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /assets/composable/visible.ts: -------------------------------------------------------------------------------- 1 | import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry"; 2 | 3 | export function useVisibleFilter(visibleKeys: Ref>) { 4 | const { isSearching } = useSearchFilter(); 5 | function filteredPayload(messages: Ref[]>) { 6 | return computed(() => { 7 | return messages.value 8 | .map((d) => { 9 | if (d instanceof ComplexLogEntry) { 10 | return ComplexLogEntry.fromLogEvent(d, visibleKeys); 11 | } else { 12 | return d; 13 | } 14 | }) 15 | .filter((d) => { 16 | if (isSearching.value && d instanceof ComplexLogEntry) { 17 | return Object.values(d.message).some((v) => JSON.stringify(v)?.includes("")); 18 | } else { 19 | return true; 20 | } 21 | }); 22 | }); 23 | } 24 | 25 | return { filteredPayload }; 26 | } 27 | -------------------------------------------------------------------------------- /assets/layouts/splash.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /assets/main.ts: -------------------------------------------------------------------------------- 1 | import "./main.css"; 2 | import { createApp, App as VueApp } from "vue"; 3 | import App from "./App.vue"; 4 | 5 | const app = createApp(App); 6 | Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.ts", { eager: true })).forEach((i) => 7 | i.install?.(app), 8 | ); 9 | app.mount("#app"); 10 | -------------------------------------------------------------------------------- /assets/models/Stack.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "@/models/Container"; 2 | 3 | export class Stack { 4 | constructor( 5 | public readonly name: string, 6 | public readonly containers: Container[], 7 | public readonly services: Service[], 8 | ) { 9 | for (const service of services) { 10 | service.stack = this; 11 | } 12 | } 13 | 14 | get updatedAt() { 15 | return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0)); 16 | } 17 | } 18 | 19 | export class Service { 20 | constructor( 21 | public readonly name: string, 22 | public readonly containers: Container[], 23 | ) {} 24 | 25 | stack?: Stack; 26 | 27 | get updatedAt() { 28 | return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/modules/i18n.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue"; 2 | import { createI18n } from "vue-i18n"; 3 | import { locale } from "@/stores/settings"; 4 | import type { Locale } from "vue-i18n"; 5 | 6 | const localesMap = Object.fromEntries( 7 | Object.entries(import.meta.glob("../../locales/*.yml")).map(([path, loadLocale]) => [ 8 | path.match(/([\w-]*)\.yml$/)?.[1], 9 | loadLocale, 10 | ]), 11 | ) as Record Promise<{ default: Record }>>; 12 | 13 | export const availableLocales = Object.keys(localesMap); 14 | 15 | function setI18nLanguage(lang: Locale) { 16 | i18n.global.locale.value = lang; 17 | return lang; 18 | } 19 | 20 | export const i18n = createI18n({ 21 | legacy: false, 22 | locale: "", 23 | fallbackLocale: "en", 24 | messages: {}, 25 | }); 26 | 27 | const loadedLanguages: string[] = []; 28 | async function loadLanguage(lang: string, setLang = true): Promise { 29 | if (setLang) { 30 | if (i18n.global.locale.value === lang) return setI18nLanguage(lang); 31 | if (loadedLanguages.includes(lang)) return setI18nLanguage(lang); 32 | } 33 | 34 | const messages = await localesMap[lang](); 35 | i18n.global.setLocaleMessage(lang, messages.default); 36 | loadedLanguages.push(lang); 37 | return setI18nLanguage(lang); 38 | } 39 | 40 | await loadLanguage("en", false); // load default language 41 | 42 | const userLocale = computed( 43 | () => 44 | locale.value || 45 | [navigator.language.toLowerCase(), navigator.language.toLowerCase().slice(0, 2)].find((l) => 46 | availableLocales.includes(l), 47 | ) || 48 | "en", 49 | ); 50 | 51 | if (userLocale.value !== "en") { 52 | await loadLanguage(userLocale.value); 53 | } 54 | 55 | watchEffect(() => loadLanguage(userLocale.value)); 56 | 57 | export const install = (app: App) => app.use(i18n); 58 | export default i18n; 59 | -------------------------------------------------------------------------------- /assets/modules/pinia.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue"; 2 | import { createPinia } from "pinia"; 3 | 4 | export const install = (app: App) => { 5 | const pinia = createPinia(); 6 | app.use(pinia); 7 | }; 8 | -------------------------------------------------------------------------------- /assets/modules/router.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue"; 2 | import { createRouter, createWebHistory } from "vue-router"; 3 | import { routes } from "vue-router/auto-routes"; 4 | import { setupLayouts } from "virtual:generated-layouts"; 5 | 6 | export const router = createRouter({ 7 | history: createWebHistory(withBase("/")), 8 | routes: setupLayouts(routes), 9 | }); 10 | 11 | export const install = (app: App) => { 12 | app.use(router); 13 | }; 14 | -------------------------------------------------------------------------------- /assets/pages/[...all].vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | -------------------------------------------------------------------------------- /assets/pages/group/[name].vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 23 | 24 | meta: 25 | menu: group 26 | 27 | -------------------------------------------------------------------------------- /assets/pages/host/[id].vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 27 | 28 | meta: 29 | menu: host 30 | 31 | -------------------------------------------------------------------------------- /assets/pages/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | 38 | -------------------------------------------------------------------------------- /assets/pages/merged/[ids].vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 23 | 24 | meta: 25 | menu: host 26 | 27 | -------------------------------------------------------------------------------- /assets/pages/service/[name].vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 29 | 30 | meta: 31 | menu: swarm 32 | 33 | -------------------------------------------------------------------------------- /assets/pages/show.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | -------------------------------------------------------------------------------- /assets/pages/stack/[name].vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 29 | 30 | meta: 31 | menu: swarm 32 | 33 | -------------------------------------------------------------------------------- /assets/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module "*.vue" { 3 | import type { DefineComponent } from "vue"; 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /assets/stores/announcements.ts: -------------------------------------------------------------------------------- 1 | type Announcement = { 2 | name: string; 3 | announcement: boolean; 4 | createdAt: Date; 5 | body: string; 6 | tag: string; 7 | htmlUrl: string; 8 | latest: boolean; 9 | mentionsCount: number; 10 | features: number; 11 | bugFixes: number; 12 | breaking: number; 13 | }; 14 | 15 | const { data: releases } = useFetch(withBase("/api/releases")).get().json(); 16 | 17 | const otherAnnouncements = [] as Announcement[]; 18 | 19 | const announcements = computed(() => { 20 | const newReleases = 21 | releases.value?.map((release) => ({ ...release, createdAt: new Date(release.createdAt), announcement: false })) ?? 22 | []; 23 | return [...newReleases, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); 24 | }); 25 | 26 | const mostRecent = computed(() => announcements.value?.[0]); 27 | const latestRelease = computed(() => announcements.value?.find((release) => release.latest && !release.announcement)); 28 | const hasRelease = computed(() => latestRelease.value !== undefined); 29 | 30 | export function useAnnouncements() { 31 | return { 32 | mostRecent, 33 | announcements, 34 | latestRelease, 35 | hasRelease, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /assets/stores/config.ts: -------------------------------------------------------------------------------- 1 | import { type Settings } from "@/stores/settings"; 2 | import { Host } from "@/stores/hosts"; 3 | 4 | const text = document.querySelector("script#config__json")?.textContent || "{}"; 5 | 6 | export interface Config { 7 | version: string; 8 | base: string; 9 | maxLogs: number; 10 | hostname: string; 11 | hosts: Host[]; 12 | authProvider: "simple" | "none" | "forward-proxy"; 13 | enableActions: boolean; 14 | enableShell: boolean; 15 | user?: { 16 | username: string; 17 | email: string; 18 | name: string; 19 | }; 20 | profile?: Profile; 21 | } 22 | 23 | export interface Profile { 24 | settings?: Settings; 25 | pinned?: Set; 26 | visibleKeys?: Map>; 27 | releaseSeen?: string; 28 | collapsedGroups?: Set; 29 | } 30 | 31 | const pageConfig = JSON.parse(text); 32 | 33 | const config: Config = { 34 | maxLogs: 400, 35 | version: "v0.0.0", 36 | hosts: [], 37 | ...pageConfig, 38 | }; 39 | 40 | export default Object.freeze(config); 41 | 42 | export const withBase = (path: string) => `${config.base}${path}`; 43 | -------------------------------------------------------------------------------- /assets/stores/hosts.ts: -------------------------------------------------------------------------------- 1 | export type Host = { 2 | id: string; 3 | name: string; 4 | nCPU: number; 5 | memTotal: number; 6 | type: "agent" | "local" | "remote" | "swarm" | "k8s"; 7 | endpoint: string; 8 | available: boolean; 9 | dockerVersion: string; 10 | agentVersion: string; 11 | }; 12 | 13 | const hosts = ref( 14 | config.hosts 15 | .sort((a, b) => a.name.localeCompare(b.name)) 16 | .reduce( 17 | (acc, item) => { 18 | acc[item.id] = item; 19 | return acc; 20 | }, 21 | {} as Record, 22 | ), 23 | ); 24 | const updateHost = (host: Host) => { 25 | delete hosts.value[host.endpoint]; 26 | hosts.value[host.id] = host; 27 | return host; 28 | }; 29 | 30 | export function useHosts() { 31 | return { 32 | hosts, 33 | updateHost, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /assets/stores/pinned.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from "pinia"; 2 | import { Ref } from "vue"; 3 | 4 | export const usePinnedLogsStore = defineStore("pinnedLogs", () => { 5 | const containerStore = useContainerStore(); 6 | const { allContainersById } = storeToRefs(containerStore); 7 | const pinnedContainerIds: Ref = ref([]); 8 | 9 | const pinnedLogs = computed(() => pinnedContainerIds.value.map((id) => allContainersById.value[id])); 10 | 11 | const pinContainer = ({ id }: { id: string }) => pinnedContainerIds.value.push(id); 12 | const unPinContainer = ({ id }: { id: string }) => 13 | pinnedContainerIds.value.splice(pinnedContainerIds.value.indexOf(id), 1); 14 | 15 | const isPinned = ({ id }: { id: string }) => pinnedContainerIds.value.includes(id); 16 | 17 | return { 18 | pinnedLogs, 19 | isPinned, 20 | pinContainer, 21 | unPinContainer, 22 | }; 23 | }); 24 | 25 | if (import.meta.hot) { 26 | import.meta.hot.accept(acceptHMRUpdate(usePinnedLogsStore, import.meta.hot)); 27 | } 28 | -------------------------------------------------------------------------------- /assets/stores/settings.ts: -------------------------------------------------------------------------------- 1 | import { toRefs } from "@vueuse/core"; 2 | 3 | export type Settings = { 4 | search: boolean; 5 | size: "small" | "medium" | "large"; 6 | compact: boolean; 7 | menuWidth: number; 8 | smallerScrollbars: boolean; 9 | showTimestamp: boolean; 10 | showStd: boolean; 11 | showAllContainers: boolean; 12 | lightTheme: "auto" | "dark" | "light"; 13 | hourStyle: "auto" | "24" | "12"; 14 | dateLocale: "auto" | "en-US" | "en-GB" | "de-DE" | "en-CA"; 15 | softWrap: boolean; 16 | collapseNav: boolean; 17 | automaticRedirect: "instant" | "delayed" | "none"; 18 | locale: string; 19 | }; 20 | export const DEFAULT_SETTINGS: Settings = { 21 | search: true, 22 | compact: false, 23 | size: "medium", 24 | menuWidth: 15, 25 | smallerScrollbars: false, 26 | showTimestamp: true, 27 | showStd: false, 28 | showAllContainers: false, 29 | lightTheme: "auto", 30 | hourStyle: "auto", 31 | dateLocale: "auto", 32 | softWrap: true, 33 | collapseNav: false, 34 | automaticRedirect: "delayed", 35 | locale: "", 36 | }; 37 | 38 | export const settings = useProfileStorage("settings", DEFAULT_SETTINGS); 39 | 40 | // @ts-ignore: automaticRedirect is now a string enum, but might be a boolean in older data 41 | if (settings.value.automaticRedirect === true) { 42 | settings.value.automaticRedirect = "delayed"; 43 | // @ts-ignore: automaticRedirect is now a string enum, but might be a boolean in older data 44 | } else if (settings.value.automaticRedirect === false) { 45 | settings.value.automaticRedirect = "none"; 46 | } 47 | 48 | export const { 49 | collapseNav, 50 | compact, 51 | softWrap, 52 | hourStyle, 53 | dateLocale, 54 | lightTheme, 55 | showAllContainers, 56 | showTimestamp, 57 | showStd, 58 | smallerScrollbars, 59 | menuWidth, 60 | size, 61 | search, 62 | locale, 63 | automaticRedirect, 64 | } = toRefs(settings); 65 | -------------------------------------------------------------------------------- /assets/typed-router.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️ 5 | // It's recommended to commit this file. 6 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. 7 | 8 | declare module 'vue-router/auto-routes' { 9 | import type { 10 | RouteRecordInfo, 11 | ParamValue, 12 | ParamValueOneOrMore, 13 | ParamValueZeroOrMore, 14 | ParamValueZeroOrOne, 15 | } from 'vue-router' 16 | 17 | /** 18 | * Route name map generated by unplugin-vue-router 19 | */ 20 | export interface RouteNamedMap { 21 | '/': RouteRecordInfo<'/', '/', Record, Record>, 22 | '/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue }, { all: ParamValue }>, 23 | '/container/[id]': RouteRecordInfo<'/container/[id]', '/container/:id', { id: ParamValue }, { id: ParamValue }>, 24 | '/group/[name]': RouteRecordInfo<'/group/[name]', '/group/:name', { name: ParamValue }, { name: ParamValue }>, 25 | '/host/[id]': RouteRecordInfo<'/host/[id]', '/host/:id', { id: ParamValue }, { id: ParamValue }>, 26 | '/login': RouteRecordInfo<'/login', '/login', Record, Record>, 27 | '/merged/[ids]': RouteRecordInfo<'/merged/[ids]', '/merged/:ids', { ids: ParamValue }, { ids: ParamValue }>, 28 | '/service/[name]': RouteRecordInfo<'/service/[name]', '/service/:name', { name: ParamValue }, { name: ParamValue }>, 29 | '/settings': RouteRecordInfo<'/settings', '/settings', Record, Record>, 30 | '/show': RouteRecordInfo<'/show', '/show', Record, Record>, 31 | '/stack/[name]': RouteRecordInfo<'/stack/[name]', '/stack/:name', { name: ParamValue }, { name: ParamValue }>, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/types/Container.d.ts: -------------------------------------------------------------------------------- 1 | export interface ContainerStat { 2 | readonly id: string; 3 | readonly cpu: number; 4 | readonly memory: number; 5 | readonly memoryUsage: number; 6 | } 7 | 8 | export type ContainerJson = { 9 | readonly id: string; 10 | readonly created: string; 11 | readonly startedAt: string; 12 | readonly finishedAt: string; 13 | readonly image: string; 14 | readonly name: string; 15 | readonly command: string; 16 | readonly status: string; 17 | readonly state: ContainerState; 18 | readonly host: string; 19 | readonly cpuLimit: number; 20 | readonly memoryLimit: number; 21 | readonly labels: Record; 22 | readonly stats: ContainerStat[]; 23 | readonly health?: ContainerHealth; 24 | readonly group?: string; 25 | }; 26 | 27 | export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting" | "deleted"; 28 | export type ContainerHealth = "healthy" | "unhealthy" | "starting"; 29 | -------------------------------------------------------------------------------- /assets/types/Point.d.ts: -------------------------------------------------------------------------------- 1 | type Point = { x: number; y: number; value?: T }; 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vite-ssg-dist 3 | .vite-ssg-temp 4 | *.local 5 | dist 6 | dist-ssr 7 | node_modules 8 | .idea/ 9 | *.log 10 | !.vscode 11 | -------------------------------------------------------------------------------- /docs/.vitepress/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | cache 3 | -------------------------------------------------------------------------------- /docs/.vitepress/activity.data.ts: -------------------------------------------------------------------------------- 1 | declare const data: { stars: number; pulls: number }; 2 | export { data }; 3 | 4 | export default { 5 | async load() { 6 | const urls = [ 7 | "https://api.github.com/repos/amir20/dozzle", 8 | "https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle", 9 | ]; 10 | 11 | const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json()))); 12 | 13 | const data = { 14 | stars: responses[0].stargazers_count, 15 | pulls: responses[1].pull_count, 16 | }; 17 | 18 | return data; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/Banner.vue: -------------------------------------------------------------------------------- 1 | 25 | 38 | 53 | 58 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/BuyMeCoffee.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/Counter.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 40 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/HeroVideo.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/Stats.vue: -------------------------------------------------------------------------------- 1 | 18 | 22 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import { h } from "vue"; 3 | import DefaultTheme from "vitepress/theme"; 4 | 5 | import "@fontsource-variable/playfair-display"; 6 | import "./style.css"; 7 | import HeroVideo from "./components/HeroVideo.vue"; 8 | import BuyMeCoffee from "./components/BuyMeCoffee.vue"; 9 | import Stats from "./components/Stats.vue"; 10 | import Banner from "./components/Banner.vue"; 11 | import Supported from "./components/Supported.vue"; 12 | 13 | export default { 14 | ...DefaultTheme, 15 | Layout: () => { 16 | return h(DefaultTheme.Layout, null, { 17 | "home-hero-image": () => h(HeroVideo), 18 | "sidebar-nav-after": () => h(BuyMeCoffee), 19 | "home-hero-actions-after": () => h(Stats), 20 | // "layout-top": () => h(Banner), 21 | "home-hero-after": () => h(Supported), 22 | }); 23 | }, 24 | enhanceApp(ctx) { 25 | DefaultTheme.enhanceApp(ctx); 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/media/dozzle-dark.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/.vitepress/theme/media/dozzle-dark.mp4 -------------------------------------------------------------------------------- /docs/.vitepress/theme/media/dozzle-light.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/.vitepress/theme/media/dozzle-light.mp4 -------------------------------------------------------------------------------- /docs/.vitepress/theme/media/dozzle-ui-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/.vitepress/theme/media/dozzle-ui-actions.png -------------------------------------------------------------------------------- /docs/.vitepress/theme/media/poster-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/.vitepress/theme/media/poster-dark.png -------------------------------------------------------------------------------- /docs/.vitepress/theme/media/poster-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/.vitepress/theme/media/poster-light.png -------------------------------------------------------------------------------- /docs/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | // biome-ignore lint: disable 6 | export {} 7 | 8 | /* prettier-ignore */ 9 | declare module 'vue' { 10 | export interface GlobalComponents { 11 | Banner: typeof import('./.vitepress/theme/components/Banner.vue')['default'] 12 | BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default'] 13 | Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default'] 14 | HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default'] 15 | RouterLink: typeof import('vue-router')['RouterLink'] 16 | RouterView: typeof import('vue-router')['RouterView'] 17 | Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default'] 18 | Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default'] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/guide/actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Container Actions 3 | --- 4 | 5 | # Using Container Actions 6 | 7 | Dozzle supports container actions, which allows you to `start`, `stop` and `restart` containers from the dropdown menu on the right next to the container stats. This feature is **disabled** by default and can be enabled by setting the environment variable `DOZZLE_ENABLE_ACTIONS` to `true`. 8 | 9 | ::: code-group 10 | 11 | ```sh 12 | docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --enable-actions 13 | ``` 14 | 15 | ```yaml [docker-compose.yml] 16 | services: 17 | dozzle: 18 | image: amir20/dozzle:latest 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | ports: 22 | - 8080:8080 23 | environment: 24 | DOZZLE_ENABLE_ACTIONS: true 25 | ``` 26 | 27 | ::: 28 | -------------------------------------------------------------------------------- /docs/guide/analytics.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Anonymous Analytics 3 | --- 4 | 5 | # Data Collection of Analytics 6 | 7 | Dozzle collects anonymous user configurations using a simple beacon written in Go. _Why?_ Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics are collected to prioritize features and fixes based on how people use Dozzle. 8 | 9 | ## Where is Data Stored 10 | 11 | Dozzle sends anonymous data to DigitalOcean, where it is written to a flat file for processing. 12 | 13 | ## Opting Out 14 | 15 | Dozzle analytics helps to prioritize features and spend time on the most important improvements. If you do not want to be tracked, use the `--no-analytics` flag or `DOZZLE_NO_ANALYTICS` environment variable. 16 | -------------------------------------------------------------------------------- /docs/guide/changing-base.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changing Application Base 3 | --- 4 | 5 | # Changing Dozzle Base 6 | 7 | Dozzle by default mounts to "/". This can be changed with the `--base` flag. For example, if you want to mount to "/foobar" then you can use `--base /foobar` or the env variable `DOZZLE_BASE`. 8 | 9 | ::: code-group 10 | 11 | ```sh 12 | docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --base /foobar 13 | ``` 14 | 15 | ```yaml [docker-compose.yml] 16 | services: 17 | dozzle: 18 | image: amir20/dozzle:latest 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | ports: 22 | - 8080:8080 23 | environment: 24 | DOZZLE_BASE: /foobar 25 | ``` 26 | 27 | ::: 28 | 29 | Dozzle will be available at `http://localhost:8080/foobar/`. This option rewrites all assets to `/foobar/{file.path}` and automatically redirects `/foobar` to `/foobar/`. 30 | 31 | ## Example with Proxy 32 | 33 | Here is an example with Nginx to proxy Dozzle with a different base: 34 | 35 | ```nginx 36 | location ^~ /foobar/ { 37 | set $upstream_app dozzle; 38 | set $upstream_port 8080; 39 | set $upstream_proto http; 40 | proxy_pass $upstream_proto://$upstream_app:$upstream_port; 41 | 42 | chunked_transfer_encoding off; 43 | proxy_buffering off; 44 | proxy_cache off; 45 | proxy_http_version 1.1; 46 | proxy_set_header Upgrade $http_upgrade; 47 | proxy_set_header Connection "upgrade"; 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/guide/container-groups.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Container Groups 3 | --- 4 | 5 | # Container Groups 6 | 7 | Dozzle performs automatic grouping of containers based on their stack name or service name. You can also create custom groups using labels. 8 | 9 | ## Default Groups 10 | 11 | By default, containers are grouped by their stack name in host mode. If `com.docker.swarm.service.name` label is present, Dozzle will automatically enable a "swarm mode" where all containers with the same service name will be joined together. 12 | 13 | ## Custom Groups 14 | 15 | Additionally, you can create custom groups by adding a label to your container. The label is `dev.dozzle.group` and the value is the name of the group. All containers with the same group name will be joined together in the UI. For example, if you have a group named `myapp`, all containers with the label `dozzle.group=myapp` will be joined together. 16 | 17 | Here is an example using Docker Compose or Docker CLI: 18 | 19 | ::: code-group 20 | 21 | ```sh 22 | docker run --label dev.dozzle.group=myapp hello-world 23 | ``` 24 | 25 | ```yaml [docker-compose.yml] 26 | services: 27 | dozzle: 28 | image: hello-world 29 | labels: 30 | - dev.dozzle.group=myapp 31 | ``` 32 | 33 | ::: 34 | -------------------------------------------------------------------------------- /docs/guide/container-names.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Container Names 3 | --- 4 | 5 | # Container Names 6 | 7 | By default, Dozzle retrieves container names directly from Docker. This is usually sufficient, as these names can be customized using the `--name` flag in `docker run` commands or through the `container_name` field in Docker Compose services. 8 | 9 | ## Custom Names 10 | 11 | In cases where modifying the container name itself isn't possible, you can override it by adding a `dev.dozzle.name` label to your container. 12 | 13 | Here is an example using Docker Compose or Docker CLI: 14 | 15 | ::: code-group 16 | 17 | ```sh 18 | docker run --label dev.dozzle.name=hello hello-world 19 | ``` 20 | 21 | ```yaml [docker-compose.yml] 22 | services: 23 | dozzle: 24 | image: hello-world 25 | labels: 26 | - dev.dozzle.name=hello 27 | ``` 28 | 29 | ::: 30 | -------------------------------------------------------------------------------- /docs/guide/debugging.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Debugging 3 | --- 4 | 5 | # Debugging with Logs 6 | 7 | By default, Dozzle does not output a lot of logs. However, this can be changed with the `--level` flag. The default value is `info` which only prints limited logs. You can use `debug` or `trace` which will show details about memory, configuration and other stats. `DOZZLE_LEVEL` can be used in compose configurations. Below is an example of using `docker-compose.yml` file to enable `debug` level. 8 | 9 | ```yaml 10 | services: 11 | dozzle: 12 | image: amir20/dozzle:latest 13 | volumes: 14 | - /var/run/docker.sock:/var/run/docker.sock 15 | ports: 16 | - 8080:8080 17 | environment: 18 | DOZZLE_LEVEL: debug 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/guide/filters.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Filter 3 | --- 4 | 5 | # Filtering Containers 6 | 7 | Dozzle supports conditional filtering similar to Docker's [--filter](https://docs.docker.com/reference/cli/docker/container/ls/#filter) with `DOZZLE_FILTER` or `--filter`. Filters are passed directly to Docker to limit what Dozzle can see. For example, filtering by label is supported with `--filter "label=color"`, which is similar to `docker ps` command with `docker ps --filter "label=color"`. 8 | 9 | ::: code-group 10 | 11 | ```sh 12 | docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --filter label=color 13 | ``` 14 | 15 | ```yaml [docker-compose.yml] 16 | services: 17 | dozzle: 18 | image: amir20/dozzle:latest 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | ports: 22 | - 8080:8080 23 | environment: 24 | DOZZLE_FILTER: label=color 25 | ``` 26 | 27 | ::: 28 | 29 | Common filters are `name` or `label` to limit Dozzle's access to containers. 30 | 31 | ## UI, Agents, and User Filters 32 | 33 | Dozzle supports multiple filters to limit the containers it can see. Filters can be set at the UI, agent, or user level. 34 | 35 | 1. **UI Filters**: These filters are applied to the Dozzle UI instance and sent to Docker to restrict the visible containers. They affect all agents and users who do not have their own filters. 36 | 2. **Agent Filters**: These filters are set at the agent level and sent to Docker to limit the containers exposed by that agent. Agent filters and UI filters work together to restrict the containers. 37 | 3. **User Filters**: These filters are set at the user level and determine which containers the user can see. If user filters are not defined, Dozzle defaults to using the UI filters. 38 | 39 | For more information on setting filters for specific users, see [user filters](/guide/authentication#setting-specific-filters-for-users). For details on setting filters for agents, see [agent filters](/guide/agent#setting-up-filters). 40 | 41 | > [!WARNING] 42 | > It is important to understand that multiple filters are combined to limit the containers. For example, if you set `--filter label=color` at the UI level and `--filter label=type` at the agent level, Dozzle will only display containers that have both the `color` and `type` labels. 43 | -------------------------------------------------------------------------------- /docs/guide/healthcheck.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Healthcheck 3 | --- 4 | 5 | # Enabling Healthcheck 6 | 7 | Dozzle has internal support for healthcheck using the `dozzle healthcheck` command. It is not enabled by default as it adds extra CPU usage. To use `healthcheck`, you need to configure it. Below is an example that checks the health of Dozzle every 3 seconds. 8 | 9 | ```yaml 10 | services: 11 | dozzle: 12 | image: amir20/dozzle:latest 13 | volumes: 14 | - /var/run/docker.sock:/var/run/docker.sock 15 | ports: 16 | - 8080:8080 17 | healthcheck: 18 | test: ["CMD", "/dozzle", "healthcheck"] 19 | interval: 3s 20 | timeout: 30s 21 | retries: 5 22 | start_period: 30s 23 | ``` 24 | 25 | `dozzle healthcheck` skips agents as they are not required for healthcheck. Agents can be configured to have their own [healthcheck](/guide/agent#setting-up-healthcheck). 26 | 27 | > [!WARNING] 28 | > The `healthcheck` command does not work with `--health-cmd` flag due to a bug in Docker. You need to use the `healthcheck` configuration in the `docker-compose.yml` file. See [Docker issue](https://github.com/docker/cli/issues/3719) for more information. 29 | -------------------------------------------------------------------------------- /docs/guide/hostname.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hostname 3 | --- 4 | 5 | # Changing Dozzle's Hostname 6 | 7 | Dozzle's default connection is called localhost. Using the `--hostname` flag, Dozzle's name can be changed to anything. This value will be shown on the page title and under the Dozzle logo. 8 | 9 | Changing the label for localhost also changes the label for the `localhost` connection which is displayed under the multi-host menu. Below is an example of using `--hostname` to change the name of Dozzle's subheader to `mywebsite.xyz`. 10 | 11 | ::: code-group 12 | 13 | ```sh 14 | docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --hostname mywebsite.xyz 15 | ``` 16 | 17 | ```yaml [docker-compose.yml] 18 | services: 19 | dozzle: 20 | image: amir20/dozzle:latest 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock 23 | ports: 24 | - 8080:8080 25 | environment: 26 | DOZZLE_HOSTNAME: mywebsite.xyz 27 | ``` 28 | 29 | ::: 30 | -------------------------------------------------------------------------------- /docs/guide/log-files-on-disk.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Following Log Files on Disk 3 | --- 4 | 5 | # Following Log Files on Disk 6 | 7 | Some containers do not write logs to `sysout` or `syserr`. Many folks have asked if Dozzle can also show logs that are written to files. Unfortunately, files in containers are not accessible to other containers, so Dozzle wouldn't have a way to access these files. Dozzle can only access logs written to `sysout` or `syserr`, which is the same functionality as the `docker logs` command. 8 | 9 | If you are creating a service using Docker, then make sure to write logs to streams. An application should not attempt to write to logfiles. Instead, delegate the logging to Docker. The [twelve factor app](https://12factor.net/logs) has a great principle around logging that explains the importance of this principle. 10 | 11 | However, there are workarounds to be able to still access files using mounts. 12 | 13 | ## Mounting Local Log Files with Alpine 14 | 15 | Dozzle reads any output stream. This can be used in combination with Alpine to `tail` a mounted file. An example of this is as follows: 16 | 17 | ::: code-group 18 | 19 | ```sh 20 | docker run -v /var/log/system.log:/var/log/test.log alpine tail -f /var/log/test.log 21 | ``` 22 | 23 | ```yaml [docker-compose.yml] 24 | services: 25 | dozzle-from-file: 26 | container_name: dozzle-from-file 27 | image: alpine 28 | volumes: 29 | - /var/log/system.log:/var/log/stream.log 30 | command: 31 | - tail 32 | - -f 33 | - /var/log/stream.log 34 | network_mode: none 35 | restart: unless-stopped 36 | ``` 37 | 38 | ::: 39 | 40 | In the above example, `/var/log/system.log` is mounted from the host and used with `tail -f` to follow the file. `tail` is smart to follow log rotations. During testing, using Alpine used about `~50KB` of memory. 41 | 42 | The second tab shows a `docker-compose` file which is useful if you want the log stream to survive server reboot. 43 | -------------------------------------------------------------------------------- /docs/guide/podman.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Podman 3 | --- 4 | 5 | # Podman 6 | 7 | Dozzle supports Podman. However, there are some issues with Podman that might prevent Dozzle from working properly. One of the main issues is that Podman doesn't create an engine-id like Docker. 8 | 9 | ## I am seeing host not found error in the logs. How do I fix it? 10 | 11 | This should be mainly a Podman only error: Using Podman doesn't create an engine-id like Docker. 12 | If you are using Docker, check if the `engine-id` file exists with correct permissions in `/var/lib/docker` and has the UUID inside. 13 | 14 | It might be necessary to clean up your existing Dozzle deployment under Podman, stop the container and remove the associated data (container/volumes). After you create the engine-id, you can redeploy the Dozzle container and your logs should now show up. 15 | 16 | ## Create UUID 17 | 18 | Options for generating UUIDs: 19 | 20 | ### uuidgen 21 | 22 | :warning: Adjust folder/file permissions if necessary. There isn't any critical info but depending on your existing setup you might want to take additional steps. 23 | 24 | 1. Install uuidgen 25 | 2. Create the folders: `mkdir -p /var/lib/docker` 26 | 3. Using uuidgen generate a UUID: `uuidgen > /var/lib/docker/engine-id` 27 | 4. Verify with `cat /var/lib/docker/engine-id` 28 | 29 | ### Ansible 30 | 31 | :warning: Depending on your setup you might have to make adjustments for file/folder permissions. The following task snippets would run as the become_user/remote_user of the playbook running these tasks. 32 | 33 | If you wish to adjust the user, you have to set individual become/become_user parameters for these tasks. 34 | 35 | ```yaml 36 | - name: Create /var/lib/docker 37 | ansible.builtin.file: 38 | path: /var/lib/docker 39 | state: directory 40 | mode: "755" 41 | 42 | - name: Create engine-id and derive UUID from hostname 43 | ansible.builtin.lineinfile: 44 | path: /var/lib/docker/engine-id 45 | line: "{{ hostname | to_uuid }}" 46 | create: true 47 | mode: "0644" 48 | insertafter: "EOF" 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/guide/shell.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Container Shell Access 3 | --- 4 | 5 | # Attaching and Executing Commands 6 | 7 | Dozzle supports attaching or executing commands within containers. It provides a web-based interface to interact with Docker containers, allowing users to attach to running containers and execute commands directly from the browser. This feature is particularly useful for debugging and troubleshooting containerized applications. This feature is **disabled** by default as it may pose security risks. To enable it, set the `DOZZLE_ENABLE_SHELL` environment variable to `true`. 8 | 9 | ::: code-group 10 | 11 | ```sh 12 | docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --enable-shell 13 | ``` 14 | 15 | ```yaml [docker-compose.yml] 16 | services: 17 | dozzle: 18 | image: amir20/dozzle:latest 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | ports: 22 | - 8080:8080 23 | environment: 24 | DOZZLE_ENABLE_SHELL: true 25 | ``` 26 | 27 | ::: 28 | 29 | > [!NOTE] 30 | > Shell access should work across all container types, including Docker, Kubernetes, and other orchestration platforms. 31 | -------------------------------------------------------------------------------- /docs/guide/what-is-dozzle.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: What is Dozzle? 3 | --- 4 | 5 | # What is Dozzle? 6 | 7 | Dozzle is an open-source project sponsored by Docker OSS. It is a log viewer designed to simplify monitoring and debugging containers. This lightweight, web-based application offers real-time log streaming, filtering, and searching capabilities through an intuitive user interface. 8 | 9 | Users can quickly access logs generated by their Docker containers, making it an essential tool for debugging and troubleshooting applications in a Docker environment. By default, Dozzle supports JSON logs with intelligent color coding. 10 | 11 | Dozzle is easy to install and configure, making it an ideal solution for developers and system administrators seeking an efficient, user-friendly log viewer for their Docker environment. The tool is available under the MIT license and is actively maintained by its developer, Amir Raminfar. 12 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | title: Home 6 | 7 | hero: 8 | name: "Dozzle" 9 | tagline: Simple Container Monitoring and Logging 10 | actions: 11 | - theme: brand 12 | text: Get Started 13 | link: /guide/getting-started 14 | - theme: alt 15 | text: View on GitHub 16 | link: https://github.com/amir20/dozzle 17 | - theme: alt 18 | text: Support 🙏🏼 19 | link: https://www.buymeacoffee.com/amirraminfar 20 | 21 | features: 22 | - title: Self Hosted 23 | details: Dozzle is a self-hosted application that runs in your own infrastructure, ensuring your logs remain private and secure. 24 | icon: 🏠 25 | - title: Real-time Logging & Monitoring 26 | details: Captures real-time Docker container logs, enabling quick and efficient issue diagnosis. 27 | icon: 🚀 28 | - title: Shell Support 29 | details: Supports shell access to containers, allowing you to attach or execute commands directly from the browser. 30 | link: /guide/shell 31 | linkText: Learn More 32 | icon: 💻 33 | - title: Multi-host Support 34 | details: UI support connecting to multiple remote hosts with a simple drop down to choose between different hosts. 35 | link: /guide/remote-hosts 36 | linkText: Learn More 37 | icon: 🌐 38 | - title: SQL Engine 39 | details: Use SQL queries to analyze logs inside your browser with WebAssembly and DuckDB. 40 | icon: 📊 41 | linkText: Learn More 42 | link: /guide/sql-engine 43 | - title: Secured Agents 44 | details: Connect to remote hosts securely with agents, providing a more secure way to connect to remote hosts. 45 | icon: 🔒 46 | link: /guide/agent 47 | linkText: Learn More 48 | - title: Swarm Support 49 | link: /guide/swarm-mode 50 | details: Supports Docker Swarm mode, allowing you to manage and monitor your swarm clusters across multiple hosts. 51 | icon: 🐳 52 | linkText: Learn More 53 | - title: Sponsored by Docker OSS 54 | details: Dozzle is open source and free to use, with the source code available on GitHub. 55 | icon: 📜 56 | link: https://hub.docker.com/r/amir20/dozzle 57 | linkText: Docker Hub 58 | --- 59 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "name": "docs", 5 | "devDependencies": { 6 | "dozzle": "workspace:*" 7 | }, 8 | "dependencies": { 9 | "@fontsource-variable/playfair-display": "^5.2.5" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | overrides: 8 | esbuild@<=0.24.2: '>=0.25.0' 9 | 10 | importers: 11 | 12 | .: 13 | dependencies: 14 | '@fontsource-variable/playfair-display': 15 | specifier: ^5.2.5 16 | version: 5.2.5 17 | devDependencies: 18 | dozzle: 19 | specifier: workspace:* 20 | version: link:.. 21 | 22 | packages: 23 | 24 | '@fontsource-variable/playfair-display@5.2.5': 25 | resolution: {integrity: sha512-nzSCC5pbWHjkJBqjPe81cPIC7fqJ6U5DwJ6tec7vF1bQ2DJnaKRBZluRQJuseFpACZLTN8LeN/1gBQEA7PnKOw==} 26 | 27 | snapshots: 28 | 29 | '@fontsource-variable/playfair-display@5.2.5': {} 30 | -------------------------------------------------------------------------------- /docs/public/_headers: -------------------------------------------------------------------------------- 1 | /assets/* 2 | cache-control: max-age=31536000 3 | cache-control: immutable 4 | -------------------------------------------------------------------------------- /docs/public/_redirects: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/public/_redirects -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/support.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Support 3 | --- 4 | 5 | # Support 6 | 7 | Dozzle is an open-source project and is maintained by its creator and contributors. If you have any questions, issues, or feature requests, please feel free to reach out to us. 8 | 9 | There are many ways to support us: 10 | 11 | - We are always looking for contributors. If you are interested in contributing to the project, please check out the [Issues](https://github.com/amir20/dozzle/issues). 12 | - If you like the project, please consider giving it a star on [GitHub](https://github.com/amir20/dozzle). 13 | - If you would like to support the project financially, you can [Buy Me a Coffee](https://www.buymeacoffee.com/amirraminfar). 14 | - Blog about the project, tweet about it, or share it on social media. 15 | 16 | Thank you for your support! 🙏 17 | -------------------------------------------------------------------------------- /docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { defineConfig } from "vite"; 4 | 5 | import Components from "unplugin-vue-components/vite"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | tailwindcss(), 10 | Components({ 11 | dirs: [path.resolve(__dirname, ".vitepress/theme/components")], 12 | extensions: ["vue", "md"], 13 | include: [/\.vue$/, /\.vue\?vue/, /\.md$/], 14 | dts: true, 15 | }), 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /e2e/agent.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("http://remote:8080/"); 5 | }); 6 | 7 | test("has right title", async ({ page }) => { 8 | await expect(page).toHaveTitle(/.* - Dozzle/); 9 | }); 10 | 11 | test("select running container", async ({ page }) => { 12 | await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click(); 13 | await expect(page).toHaveURL(/\/container/); 14 | await expect(page.getByText("Accepting connections")).toBeVisible(); 15 | }); 16 | -------------------------------------------------------------------------------- /e2e/custom.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("http://custom_base:8080/foobarbase"); 5 | }); 6 | 7 | test("has right title", async ({ page }) => { 8 | await expect(page).toHaveTitle(/.* - Dozzle/); 9 | }); 10 | 11 | test("url should have custom base", async ({ page }) => { 12 | await expect(page).toHaveURL(/foobarbase/); 13 | }); 14 | -------------------------------------------------------------------------------- /e2e/data/users.yml: -------------------------------------------------------------------------------- 1 | users: 2 | admin: 3 | name: Admin 4 | password: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" 5 | -------------------------------------------------------------------------------- /e2e/default.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("http://dozzle:8080/"); 5 | }); 6 | 7 | test("has right title", async ({ page }) => { 8 | await expect(page).toHaveTitle(/.* - Dozzle/); 9 | }); 10 | 11 | test("has dashboard text", async ({ page }) => { 12 | await expect(page.getByText("container name")).toBeVisible(); 13 | }); 14 | 15 | test("click on settings button", async ({ page }) => { 16 | await page.getByTestId("settings").click(); 17 | await expect(page.getByRole("heading", { name: "About" })).toBeVisible(); 18 | }); 19 | 20 | test("shortcut for fuzzy search", async ({ page }) => { 21 | await page.locator("body").press("Control+k"); 22 | await expect(page.locator(".modal").getByPlaceholder("Search containers (⌘ + k, ⌃k)")).toBeVisible(); 23 | }); 24 | 25 | test("route by name", async ({ page }) => { 26 | await page.goto("http://dozzle:8080/show?name=dozzle"); 27 | await expect(page).toHaveURL(/\/container/); 28 | }); 29 | 30 | test.describe("es locale", () => { 31 | test.use({ locale: "es" }); 32 | 33 | test("translated text", async ({ page }) => { 34 | await expect(page.getByTestId("search")).toContainText("Buscar"); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /e2e/remote.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("http://dozzle-with-agent:8080/"); 5 | }); 6 | 7 | test("has right title", async ({ page }) => { 8 | await expect(page).toHaveTitle(/.* - Dozzle/); 9 | }); 10 | 11 | test("select running container", async ({ page }) => { 12 | await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click(); 13 | await expect(page).toHaveURL(/\/container/); 14 | await expect(page.getByText("Accepting connections")).toBeVisible(); 15 | }); 16 | -------------------------------------------------------------------------------- /e2e/simple.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("simple authentication", async ({ page }) => { 4 | await page.goto("http://simple-auth:8080/"); 5 | await page.locator('input[name="username"]').fill("admin"); 6 | await page.locator('input[name="password"]').fill("password"); 7 | await page.locator('button[type="submit"]').click(); 8 | await expect(page.getByTestId("settings")).toBeVisible(); 9 | }); 10 | -------------------------------------------------------------------------------- /e2e/visual.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("http://dozzle:8080/"); 5 | }); 6 | 7 | test.describe("default", () => { 8 | test("homepage", async ({ page, isMobile }) => { 9 | if (isMobile) { 10 | await page.getByTestId("hamburger").click(); 11 | } 12 | await expect(page.getByTestId("navigation")).toHaveScreenshot(); 13 | }); 14 | }); 15 | 16 | test.describe("dark", () => { 17 | test.use({ colorScheme: "dark" }); 18 | test("homepage", async ({ page, isMobile }) => { 19 | if (isMobile) { 20 | await page.getByTestId("hamburger").click(); 21 | } 22 | await expect(page.getByTestId("navigation")).toHaveScreenshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png -------------------------------------------------------------------------------- /e2e/visual.spec.ts-snapshots/dark-homepage-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/e2e/visual.spec.ts-snapshots/dark-homepage-1-chromium-linux.png -------------------------------------------------------------------------------- /e2e/visual.spec.ts-snapshots/default-homepage-1-Mobile-Chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/e2e/visual.spec.ts-snapshots/default-homepage-1-Mobile-Chrome-linux.png -------------------------------------------------------------------------------- /e2e/visual.spec.ts-snapshots/default-homepage-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/e2e/visual.spec.ts-snapshots/default-homepage-1-chromium-linux.png -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > This directory contains examples that are used for testing and demonstration purposes. The configurations may vary and use different tags that are not wanted for the production environment. 3 | 4 | This directory contains scripts to setup swarm, k8s, and other environments. There is also examples to demonstrate how to use Dozzle with different environments. I use these examples to test and demonstrate the functionality of Dozzle. 5 | 6 | For official documentation, please visit [https://dozzle.dev](https://dozzle.dev). 7 | -------------------------------------------------------------------------------- /examples/docker.agents-with-certs.yml: -------------------------------------------------------------------------------- 1 | services: 2 | agent: 3 | image: amir20/dozzle:pr-3196 4 | command: agent 5 | volumes: 6 | - /var/run/docker.sock:/var/run/docker.sock 7 | secrets: 8 | - source: cert 9 | target: /dozzle_cert.pem 10 | - source: key 11 | target: /dozzle_key.pem 12 | ports: 13 | - 7007:7007 14 | secrets: 15 | cert: 16 | file: ./cert.pem 17 | key: 18 | file: ./key.pem 19 | -------------------------------------------------------------------------------- /examples/docker.agents.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dozzle: 3 | image: amir20/dozzle:latest 4 | command: agent 5 | environment: 6 | - DOZZLE_LEVEL=debug 7 | volumes: 8 | - /var/run/docker.sock:/var/run/docker.sock 9 | ports: 10 | - "7007:7007" 11 | deploy: 12 | mode: global 13 | -------------------------------------------------------------------------------- /examples/docker.swarm.auth.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dozzle: 3 | image: amir20/dozzle:latest 4 | environment: 5 | - DOZZLE_LEVEL=debug 6 | - DOZZLE_MODE=swarm 7 | - DOZZLE_AUTH_PROVIDER=simple 8 | volumes: 9 | - /var/run/docker.sock:/var/run/docker.sock 10 | secrets: 11 | - source: users 12 | target: /data/users.yml 13 | ports: 14 | - "8080:8080" 15 | networks: 16 | - dozzle 17 | deploy: 18 | mode: global 19 | 20 | networks: 21 | dozzle: 22 | driver: overlay 23 | secrets: 24 | users: 25 | file: users.yml 26 | -------------------------------------------------------------------------------- /examples/docker.swarm.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dozzle-service: 3 | image: amir20/dozzle:local-test 4 | environment: 5 | - DOZZLE_LEVEL=debug 6 | - DOZZLE_MODE=swarm 7 | healthcheck: 8 | test: ["CMD", "/dozzle", "healthcheck"] 9 | interval: 3s 10 | timeout: 30s 11 | retries: 5 12 | start_period: 30s 13 | volumes: 14 | - /var/run/docker.sock:/var/run/docker.sock 15 | ports: 16 | - "8080:8080" 17 | networks: 18 | - dozzle 19 | deploy: 20 | mode: global 21 | 22 | networks: 23 | dozzle: 24 | driver: overlay 25 | -------------------------------------------------------------------------------- /examples/ingress.yml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: dozzle-ingress 5 | annotations: 6 | ingress.kubernetes.io/ssl-redirect: "false" 7 | spec: 8 | rules: 9 | - host: dozzle.k3d.local 10 | http: 11 | paths: 12 | - path: / 13 | pathType: Prefix 14 | backend: 15 | service: 16 | name: dozzle-service 17 | port: 18 | number: 8080 19 | -------------------------------------------------------------------------------- /examples/k8s.dozzle.yml: -------------------------------------------------------------------------------- 1 | # rbac.yaml 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: pod-viewer 6 | --- 7 | # clusterrole.yaml 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | name: pod-viewer-role 12 | rules: 13 | - apiGroups: [""] 14 | resources: ["pods", "pods/log", "nodes"] 15 | verbs: ["get", "list", "watch"] 16 | - apiGroups: ["metrics.k8s.io"] 17 | resources: ["pods"] 18 | verbs: ["get", "list"] 19 | --- 20 | # clusterrolebinding.yaml 21 | apiVersion: rbac.authorization.k8s.io/v1 22 | kind: ClusterRoleBinding 23 | metadata: 24 | name: pod-viewer-binding 25 | subjects: 26 | - kind: ServiceAccount 27 | name: pod-viewer 28 | namespace: default 29 | roleRef: 30 | kind: ClusterRole 31 | name: pod-viewer-role 32 | apiGroup: rbac.authorization.k8s.io 33 | --- 34 | # deployment.yaml 35 | apiVersion: apps/v1 36 | kind: Deployment 37 | metadata: 38 | name: dozzle 39 | spec: 40 | replicas: 1 41 | selector: 42 | matchLabels: 43 | app: dozzle 44 | template: 45 | metadata: 46 | labels: 47 | app: dozzle 48 | spec: 49 | serviceAccountName: pod-viewer 50 | containers: 51 | - name: dozzle 52 | image: amir20/dozzle:latest 53 | imagePullPolicy: Never 54 | ports: 55 | - containerPort: 8080 56 | env: 57 | - name: DOZZLE_MODE 58 | value: "k8s" 59 | - name: DOZZLE_LEVEL 60 | value: "debug" 61 | --- 62 | # service.yaml 63 | apiVersion: v1 64 | kind: Service 65 | metadata: 66 | name: dozzle-service 67 | spec: 68 | type: ClusterIP 69 | selector: 70 | app: dozzle 71 | ports: 72 | - port: 8080 73 | targetPort: 8080 74 | protocol: TCP 75 | -------------------------------------------------------------------------------- /examples/setup-swarm.fish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env fish 2 | 3 | # docker network create --driver bridge swarm-net 4 | 5 | echo "Removing existing containers" 6 | docker rm -f manager worker-1 worker-2 7 | 8 | echo "Creating manager" 9 | docker run -d --name manager \ 10 | --privileged \ 11 | --network swarm-net \ 12 | --network-alias manager \ 13 | -p 2377:2377 \ 14 | -p 7946:7946 \ 15 | -p 4789:4789 \ 16 | -p 8000-9000:8000-9000 \ 17 | -v ./examples:/examples \ 18 | docker 19 | 20 | # Store join command in a variable 21 | sleep 2 22 | echo "Initializing swarm" 23 | set JOIN_COMMAND (docker exec manager docker swarm init | grep "swarm join --token") 24 | 25 | echo "Creating workers" 26 | for i in 1 2 27 | docker run -d --name worker-$i \ 28 | --privileged \ 29 | --network swarm-net \ 30 | --network-alias worker-$i \ 31 | docker 32 | end 33 | 34 | sleep 2 35 | 36 | for i in 1 2 37 | echo "Joining worker-$i to swarm" 38 | docker exec worker-$i sh -c "$JOIN_COMMAND" 39 | end 40 | 41 | function cleanup 42 | docker rm -f manager worker-1 worker-2 43 | end 44 | 45 | function swarm 46 | docker exec manager docker $argv 47 | end 48 | -------------------------------------------------------------------------------- /examples/users.yml: -------------------------------------------------------------------------------- 1 | users: 2 | amir: 3 | email: "" 4 | name: Amir 5 | password: $2a$11$nXizfBMJrSwfo4ofkS62denLyCKX0X7VsmfTbMyD3thTkmuGNp8.m 6 | -------------------------------------------------------------------------------- /internal/analytics/http_beacon.go: -------------------------------------------------------------------------------- 1 | package analytics 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httputil" 9 | 10 | "github.com/amir20/dozzle/types" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | func SendBeacon(e types.BeaconEvent) error { 15 | log.Trace().Interface("event", e).Msg("sending beacon") 16 | jsonValue, err := json.Marshal(e) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | req, err := http.NewRequest("POST", "https://b.dozzle.dev/event", bytes.NewBuffer(jsonValue)) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | response, err := http.DefaultClient.Do(req) 27 | if err != nil { 28 | return err 29 | } 30 | defer response.Body.Close() 31 | 32 | if response.StatusCode/100 != 2 { 33 | dump, err := httputil.DumpResponse(response, true) 34 | if err != nil { 35 | return err 36 | } 37 | log.Debug().Str("response", string(dump)).Msg("google analytics returned non-2xx status code") 38 | return fmt.Errorf("google analytics returned non-2xx status code: %v", response.Status) 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/auth/proxy.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/amir20/dozzle/internal/container" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | type contextKey string 15 | 16 | const remoteUser contextKey = "remoteUser" 17 | 18 | type proxyAuthContext struct { 19 | headerUser string 20 | headerEmail string 21 | headerName string 22 | headerFilter string 23 | } 24 | 25 | func hashEmail(email string) string { 26 | email = strings.TrimSpace(email) 27 | email = strings.ToLower(email) 28 | hash := md5.Sum([]byte(email)) 29 | 30 | return hex.EncodeToString(hash[:]) 31 | } 32 | 33 | func NewForwardProxyAuth(userHeader, emailHeader, nameHeader, filterHeader string) *proxyAuthContext { 34 | return &proxyAuthContext{ 35 | headerUser: userHeader, 36 | headerEmail: emailHeader, 37 | headerName: nameHeader, 38 | headerFilter: filterHeader, 39 | } 40 | } 41 | 42 | func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | if r.Header.Get(p.headerUser) != "" { 45 | containerFilter, err := container.ParseContainerFilter(r.Header.Get(p.headerFilter)) 46 | if err != nil { 47 | log.Fatal().Str("filter", r.Header.Get(p.headerFilter)).Msg("Failed to parse container filter") 48 | } 49 | user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter) 50 | ctx := context.WithValue(r.Context(), remoteUser, user) 51 | next.ServeHTTP(w, r.WithContext(ctx)) 52 | } else { 53 | next.ServeHTTP(w, r) 54 | } 55 | }) 56 | } 57 | 58 | func (p *proxyAuthContext) CreateToken(username, password string) (string, error) { 59 | log.Fatal().Msg("CreateToken not implemented in proxy auth") 60 | return "", nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/auth/simple.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/sha256" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-chi/jwtauth/v5" 10 | ) 11 | 12 | type simpleAuthContext struct { 13 | UserDatabase UserDatabase 14 | tokenAuth *jwtauth.JWTAuth 15 | ttl time.Duration 16 | } 17 | 18 | var ErrInvalidCredentials = errors.New("invalid credentials") 19 | 20 | func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext { 21 | h := sha256.New() 22 | for _, user := range userDatabase.Users { 23 | h.Write([]byte(user.Password)) 24 | } 25 | 26 | tokenAuth := jwtauth.New("HS256", h.Sum(nil), nil) 27 | 28 | return &simpleAuthContext{ 29 | UserDatabase: userDatabase, 30 | tokenAuth: tokenAuth, 31 | ttl: ttl, 32 | } 33 | } 34 | 35 | func (a *simpleAuthContext) CreateToken(username, password string) (string, error) { 36 | user := a.UserDatabase.FindByPassword(username, password) 37 | if user == nil { 38 | return "", ErrInvalidCredentials 39 | } 40 | 41 | claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter} 42 | jwtauth.SetIssuedNow(claims) 43 | 44 | if a.ttl > 0 { 45 | jwtauth.SetExpiryIn(claims, a.ttl) 46 | } 47 | 48 | _, tokenString, err := a.tokenAuth.Encode(claims) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | return tokenString, nil 54 | } 55 | 56 | func (a *simpleAuthContext) AuthMiddleware(next http.Handler) http.Handler { 57 | return jwtauth.Verifier(a.tokenAuth)(next) 58 | } 59 | -------------------------------------------------------------------------------- /internal/cache/expire.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | type Cache[T any] struct { 11 | f func() (T, error) 12 | Timestamp time.Time 13 | Duration time.Duration 14 | Data T 15 | mu sync.Mutex 16 | } 17 | 18 | func New[T any](f func() (T, error), duration time.Duration) *Cache[T] { 19 | return &Cache[T]{ 20 | f: f, 21 | Duration: duration, 22 | } 23 | } 24 | 25 | func (c *Cache[T]) GetWithHit() (T, error, bool) { 26 | c.mu.Lock() 27 | defer c.mu.Unlock() 28 | hit := true 29 | if c.Timestamp.IsZero() || time.Since(c.Timestamp) > c.Duration { 30 | hit = false 31 | 32 | var err error 33 | c.Data, err = c.f() 34 | if err != nil { 35 | return c.Data, err, hit 36 | } 37 | c.Timestamp = time.Now() 38 | } 39 | log.Debug().Bool("hit", hit).Type("data", c.Data).Msg("Cache hit") 40 | return c.Data, nil, hit 41 | } 42 | 43 | func (c *Cache[T]) Get() (T, error) { 44 | data, err, _ := c.GetWithHit() 45 | return data, err 46 | } 47 | -------------------------------------------------------------------------------- /internal/container/client.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | ) 8 | 9 | type StdType int 10 | 11 | const ( 12 | UNKNOWN StdType = 1 << iota 13 | STDOUT 14 | STDERR 15 | ) 16 | const STDALL = STDOUT | STDERR 17 | 18 | func (s StdType) String() string { 19 | switch s { 20 | case STDOUT: 21 | return "stdout" 22 | case STDERR: 23 | return "stderr" 24 | case STDALL: 25 | return "all" 26 | default: 27 | return "unknown" 28 | } 29 | } 30 | 31 | type Client interface { 32 | ListContainers(context.Context, ContainerLabels) ([]Container, error) 33 | FindContainer(context.Context, string) (Container, error) 34 | ContainerLogs(context.Context, string, time.Time, StdType) (io.ReadCloser, error) 35 | ContainerEvents(context.Context, chan<- ContainerEvent) error 36 | ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error) 37 | ContainerStats(context.Context, string, chan<- ContainerStat) error 38 | Ping(context.Context) error 39 | Host() Host 40 | ContainerActions(ctx context.Context, action ContainerAction, containerID string) error 41 | ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error) 42 | ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error) 43 | } 44 | -------------------------------------------------------------------------------- /internal/container/logfmt.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "errors" 5 | 6 | orderedmap "github.com/wk8/go-ordered-map/v2" 7 | ) 8 | 9 | // ParseLogFmt parses a log entry in logfmt format and returns a map of key-value pairs. 10 | func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) { 11 | result := orderedmap.New[string, string]() 12 | var key, value string 13 | inQuotes, escaping, isKey := false, false, true 14 | start := 0 15 | 16 | for i := 0; i < len(log); i++ { 17 | char := log[i] 18 | if isKey { 19 | if char == '=' { 20 | if start >= i { 21 | return nil, errors.New("invalid format: key is empty") 22 | } 23 | key = log[start:i] 24 | isKey = false 25 | start = i + 1 26 | } else if char == ' ' { 27 | if i > start { 28 | return nil, errors.New("invalid format: unexpected space in key") 29 | } 30 | } 31 | 32 | } else { 33 | if inQuotes { 34 | if escaping { 35 | escaping = false 36 | } else if char == '\\' { 37 | escaping = true 38 | } else if char == '"' { 39 | value = log[start:i] 40 | result.Set(key, value) 41 | inQuotes = false 42 | isKey = true 43 | start = i + 2 44 | } 45 | } else { 46 | if char == '"' { 47 | inQuotes = true 48 | start = i + 1 49 | } else if char == ' ' { 50 | value = log[start:i] 51 | result.Set(key, value) 52 | isKey = true 53 | start = i + 1 54 | } 55 | } 56 | } 57 | } 58 | 59 | // Handle the last key-value pair if there is no trailing space 60 | if !isKey && start < len(log) { 61 | if inQuotes { 62 | return nil, errors.New("invalid format: unclosed quotes") 63 | } 64 | value = log[start:] 65 | result.Set(key, value) 66 | } else if isKey && start < len(log) { 67 | return nil, errors.New("invalid format: unexpected key without value") 68 | } 69 | 70 | if !isKey { 71 | if inQuotes { 72 | return nil, errors.New("invalid format: unclosed quotes") 73 | } 74 | value = log[start:] 75 | result.Set(key, value) 76 | } 77 | 78 | return result, nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/container/stripansi.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 8 | 9 | var re = regexp.MustCompile(ansi) 10 | 11 | func stripANSI(str string) string { 12 | return re.ReplaceAllString(str, "") 13 | } 14 | -------------------------------------------------------------------------------- /internal/container/types_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/amir20/dozzle/internal/utils" 7 | "github.com/go-faker/faker/v4" 8 | "github.com/go-faker/faker/v4/pkg/options" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestProto(t *testing.T) { 13 | expected := Container{} 14 | faker.FakeData(&expected, options.WithFieldsToIgnore("Stats")) 15 | expected.FinishedAt = expected.FinishedAt.UTC() 16 | expected.Created = expected.Created.UTC() 17 | expected.StartedAt = expected.StartedAt.UTC() 18 | expected.Stats = utils.NewRingBuffer[ContainerStat](300) 19 | 20 | pb := expected.ToProto() 21 | actual := FromProto(&pb) 22 | 23 | assert.Equal(t, expected, actual) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /internal/docker/calculation_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/container" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_calculateMemUsageUnixNoCache(t *testing.T) { 11 | type args struct { 12 | mem container.MemoryStats 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | want float64 18 | }{ 19 | { 20 | name: "with cgroup v1", 21 | args: args{ 22 | mem: container.MemoryStats{ 23 | Usage: 100, 24 | Stats: map[string]uint64{ 25 | "total_inactive_file": 1, 26 | }, 27 | }, 28 | }, 29 | want: 99, 30 | }, 31 | { 32 | name: "with cgroup v2", 33 | args: args{ 34 | mem: container.MemoryStats{ 35 | Usage: 100, 36 | Stats: map[string]uint64{ 37 | "inactive_file": 2, 38 | }, 39 | }, 40 | }, 41 | want: 98, 42 | }, 43 | { 44 | name: "without cgroup", 45 | args: args{ 46 | mem: container.MemoryStats{ 47 | Usage: 100, 48 | }, 49 | }, 50 | want: 100, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | assert.Equalf(t, tt.want, calculateMemUsageUnixNoCache(tt.args.mem), "calculateMemUsageUnixNoCache(%v)", tt.args.mem) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/docker/log_reader.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/binary" 7 | "errors" 8 | "io" 9 | "sync" 10 | 11 | "github.com/amir20/dozzle/internal/container" 12 | ) 13 | 14 | var ErrBadHeader = errors.New("bad header") 15 | 16 | type StdType int 17 | 18 | const ( 19 | stdout StdType = iota 20 | stderr 21 | ) 22 | 23 | type LogReader struct { 24 | reader *bufio.Reader 25 | tty bool 26 | pool *sync.Pool 27 | } 28 | 29 | func NewLogReader(r io.Reader, tty bool) *LogReader { 30 | return &LogReader{ 31 | reader: bufio.NewReader(r), 32 | tty: tty, 33 | pool: &sync.Pool{ 34 | New: func() interface{} { 35 | return bytes.NewBuffer(make([]byte, 0, 4096)) 36 | }, 37 | }, 38 | } 39 | } 40 | 41 | func (d *LogReader) Read() (string, container.StdType, error) { 42 | message, stdType, err := d.readEvent() 43 | if err != nil { 44 | return "", 0, err 45 | } 46 | 47 | var std container.StdType 48 | switch stdType { 49 | case stdout: 50 | std = container.STDOUT 51 | case stderr: 52 | std = container.STDERR 53 | } 54 | 55 | return message, std, nil 56 | 57 | } 58 | 59 | func (d *LogReader) readEvent() (string, StdType, error) { 60 | header := []byte{0, 0, 0, 0, 0, 0, 0, 0} 61 | buffer := d.pool.Get().(*bytes.Buffer) 62 | buffer.Reset() 63 | defer d.pool.Put(buffer) 64 | 65 | var streamType StdType = stdout 66 | 67 | if d.tty { 68 | message, err := d.reader.ReadString('\n') 69 | if err != nil { 70 | return message, streamType, err 71 | } 72 | return message, streamType, nil 73 | } else { 74 | n, err := io.ReadFull(d.reader, header) 75 | if err != nil { 76 | return "", streamType, err 77 | } 78 | if n != 8 { 79 | message, _ := d.reader.ReadString('\n') 80 | return message, streamType, ErrBadHeader 81 | } 82 | 83 | switch header[0] { 84 | case 1: 85 | streamType = stdout 86 | case 2: 87 | streamType = stderr 88 | } 89 | 90 | count := binary.BigEndian.Uint32(header[4:]) 91 | if count == 0 { 92 | return "", streamType, nil 93 | } 94 | 95 | _, err = io.CopyN(buffer, d.reader, int64(count)) 96 | if err != nil { 97 | return "", streamType, err 98 | } 99 | return buffer.String(), streamType, nil 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/healthcheck/http.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func HttpRequest(addr string, base string) error { 12 | if strings.HasPrefix(addr, ":") { 13 | addr = "localhost" + addr 14 | } 15 | 16 | if base == "/" { 17 | base = "" 18 | } 19 | 20 | url := fmt.Sprintf("%s%s/healthcheck", addr, base) 21 | 22 | if !strings.HasPrefix(url, "http") { 23 | url = "http://" + url 24 | } 25 | 26 | log.Info().Str("url", url).Msg("performing healthcheck") 27 | resp, err := http.Get(url) 28 | 29 | if err != nil { 30 | return err 31 | } 32 | defer resp.Body.Close() 33 | 34 | if resp.StatusCode == 200 { 35 | return nil 36 | } 37 | 38 | return fmt.Errorf("healthcheck failed with status code %d", resp.StatusCode) 39 | } 40 | -------------------------------------------------------------------------------- /internal/healthcheck/http_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestHttpRequest(t *testing.T) { 10 | // Test server that always responds with a status code of 200 11 | server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 12 | rw.WriteHeader(http.StatusOK) 13 | })) 14 | defer server.Close() 15 | 16 | // Test server that always responds with a status code of 500 17 | errorServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 18 | rw.WriteHeader(http.StatusInternalServerError) 19 | })) 20 | defer errorServer.Close() 21 | 22 | tests := []struct { 23 | name string 24 | addr string 25 | base string 26 | wantErr bool 27 | }{ 28 | { 29 | name: "Healthcheck OK", 30 | addr: server.URL, 31 | base: "/", 32 | wantErr: false, 33 | }, 34 | { 35 | name: "Healthcheck Fail", 36 | addr: errorServer.URL, 37 | base: "/", 38 | wantErr: true, 39 | }, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | err := HttpRequest(tt.addr, tt.base) 45 | 46 | if (err != nil) != tt.wantErr { 47 | t.Errorf("HttpRequest() error = %v, wantErr %v", err, tt.wantErr) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/healthcheck/rpc.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | 7 | "github.com/amir20/dozzle/internal/agent" 8 | "github.com/amir20/dozzle/internal/container" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | func RPCRequest(ctx context.Context, addr string, certs tls.Certificate) error { 13 | client, err := agent.NewClient(addr, certs) 14 | if err != nil { 15 | log.Fatal().Err(err).Msg("Failed to create agent client") 16 | } 17 | containers, err := client.ListContainers(ctx, container.ContainerLabels{}) 18 | log.Trace().Int("containers", len(containers)).Msg("Healtcheck RPC request completed") 19 | return err 20 | } 21 | -------------------------------------------------------------------------------- /internal/k8s/log_reader.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | 7 | "github.com/amir20/dozzle/internal/container" 8 | ) 9 | 10 | type LogReader struct { 11 | reader *bufio.Reader 12 | } 13 | 14 | func NewLogReader(reader io.ReadCloser) *LogReader { 15 | return &LogReader{ 16 | reader: bufio.NewReader(reader), 17 | } 18 | } 19 | 20 | func (r *LogReader) Read() (string, container.StdType, error) { 21 | line, err := r.reader.ReadString('\n') 22 | if err != nil { 23 | return "", 0, err 24 | } 25 | 26 | return line, container.STDOUT, nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/support/cli/agent_command.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "io" 8 | "net" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/amir20/dozzle/internal/agent" 14 | "github.com/amir20/dozzle/internal/docker" 15 | "github.com/rs/zerolog/log" 16 | ) 17 | 18 | type AgentCmd struct { 19 | Addr string `arg:"--agent-addr,env:DOZZLE_AGENT_ADDR" default:":7007" help:"sets the host:port to bind for the agent"` 20 | } 21 | 22 | func (a *AgentCmd) Run(args Args, embeddedCerts embed.FS) error { 23 | if args.Mode != "server" { 24 | return fmt.Errorf("agent command is only available in server mode") 25 | } 26 | client, err := docker.NewLocalClient(args.Hostname) 27 | if err != nil { 28 | return fmt.Errorf("failed to create docker client: %w", err) 29 | } 30 | certs, err := ReadCertificates(embeddedCerts) 31 | if err != nil { 32 | return fmt.Errorf("failed to read certificates: %w", err) 33 | } 34 | 35 | listener, err := net.Listen("tcp", args.Agent.Addr) 36 | if err != nil { 37 | return fmt.Errorf("failed to listen: %w", err) 38 | } 39 | tempFile, err := os.CreateTemp("", "agent-*.addr") 40 | if err != nil { 41 | return fmt.Errorf("failed to create temp file: %w", err) 42 | } 43 | io.WriteString(tempFile, listener.Addr().String()) 44 | log.Debug().Str("file", tempFile.Name()).Msg("Created temp file") 45 | go StartEvent(args, "", client, "agent") 46 | server, err := agent.NewServer(client, certs, args.Version(), args.Filter) 47 | if err != nil { 48 | return fmt.Errorf("failed to create agent server: %w", err) 49 | } 50 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 51 | defer stop() 52 | go func() { 53 | log.Info().Msgf("Dozzle agent version %s", args.Version()) 54 | log.Info().Msgf("Agent listening on %s", listener.Addr().String()) 55 | 56 | if err := server.Serve(listener); err != nil { 57 | log.Error().Err(err).Msg("failed to serve") 58 | } 59 | }() 60 | <-ctx.Done() 61 | stop() 62 | log.Info().Msg("Shutting down agent") 63 | server.Stop() 64 | log.Debug().Str("file", tempFile.Name()).Msg("Removing temp file") 65 | os.Remove(tempFile.Name()) 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/support/cli/agent_test_command.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | 8 | "github.com/amir20/dozzle/internal/agent" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | type AgentTestCmd struct { 13 | Address string `arg:"positional"` 14 | } 15 | 16 | func (at *AgentTestCmd) Run(args Args, embeddedCerts embed.FS) error { 17 | certs, err := ReadCertificates(embeddedCerts) 18 | if err != nil { 19 | return fmt.Errorf("error reading certificates: %w", err) 20 | } 21 | 22 | log.Info().Str("endpoint", args.AgentTest.Address).Msg("Connecting to agent") 23 | 24 | agent, err := agent.NewClient(args.AgentTest.Address, certs) 25 | if err != nil { 26 | return fmt.Errorf("error connecting to agent: %w", err) 27 | } 28 | ctx, cancel := context.WithTimeout(context.Background(), args.Timeout) 29 | defer cancel() 30 | host, err := agent.Host(ctx) 31 | if err != nil { 32 | return fmt.Errorf("error fetching host info for agent: %w", err) 33 | } 34 | 35 | log.Info().Str("endpoint", args.AgentTest.Address).Str("version", host.AgentVersion).Str("name", host.Name).Str("id", host.ID).Msg("Successfully connected to agent") 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/support/cli/analytics.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/amir20/dozzle/internal/analytics" 5 | "github.com/amir20/dozzle/internal/container" 6 | "github.com/amir20/dozzle/types" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func StartEvent(args Args, mode string, client container.Client, subCommand string) { 11 | if args.NoAnalytics { 12 | return 13 | } 14 | event := types.BeaconEvent{ 15 | Name: "start", 16 | Version: args.Version(), 17 | Mode: mode, 18 | RemoteAgents: len(args.RemoteAgent), 19 | RemoteClients: len(args.RemoteHost), 20 | SubCommand: subCommand, 21 | HasActions: args.EnableActions, 22 | HasShell: args.EnableShell, 23 | HasCustomAddress: args.Addr != ":8080", 24 | HasCustomBase: args.Base != "/", 25 | HasHostname: args.Hostname != "", 26 | FilterLength: len(args.Filter), 27 | } 28 | 29 | if client != nil { 30 | host := client.Host() 31 | event.ServerID = host.ID 32 | event.ServerVersion = host.DockerVersion 33 | event.IsSwarmMode = host.Swarm 34 | } else { 35 | event.ServerID = "n/a" 36 | } 37 | 38 | log.Trace().Interface("event", event).Msg("Sending analytics event") 39 | if err := analytics.SendBeacon(event); err != nil { 40 | log.Debug().Err(err).Msg("Failed to send analytics event") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/support/cli/certs.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "crypto/tls" 5 | "embed" 6 | "os" 7 | 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func ReadCertificates(certs embed.FS) (tls.Certificate, error) { 12 | if pair, err := tls.LoadX509KeyPair("dozzle_cert.pem", "dozzle_key.pem"); err == nil { 13 | log.Info().Msg("Loaded custom dozzle certificate and key") 14 | return pair, nil 15 | } else { 16 | if !os.IsNotExist(err) { 17 | log.Fatal().Err(err).Msg("Failed to load custom dozzle certificate and key. Stopping...") 18 | } 19 | } 20 | 21 | cert, err := certs.ReadFile("shared_cert.pem") 22 | if err != nil { 23 | return tls.Certificate{}, err 24 | } 25 | 26 | key, err := certs.ReadFile("shared_key.pem") 27 | if err != nil { 28 | return tls.Certificate{}, err 29 | } 30 | 31 | return tls.X509KeyPair(cert, key) 32 | } 33 | -------------------------------------------------------------------------------- /internal/support/cli/generate_command.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/amir20/dozzle/internal/auth" 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | type GenerateCmd struct { 14 | Username string `arg:"positional"` 15 | Password string `arg:"--password, -p" help:"sets the password for the user"` 16 | Name string `arg:"--name, -n" help:"sets the display name for the user"` 17 | Email string `arg:"--email, -e" help:"sets the email for the user"` 18 | Filter string `arg:"--user-filter" help:"sets the filter for the user. This can be a comma separated list of filters."` 19 | } 20 | 21 | func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error { 22 | writer := zerolog.NewConsoleWriter() 23 | log.Logger = log.Output(writer) 24 | StartEvent(args, "", nil, "generate") 25 | if args.Generate.Username == "" || args.Generate.Password == "" { 26 | return fmt.Errorf("username and password are required") 27 | } 28 | 29 | buffer := auth.GenerateUsers(auth.User{ 30 | Username: args.Generate.Username, 31 | Password: args.Generate.Password, 32 | Name: args.Generate.Name, 33 | Email: args.Generate.Email, 34 | Filter: args.Generate.Filter, 35 | }, true) 36 | 37 | if _, err := os.Stdout.Write(buffer.Bytes()); err != nil { 38 | return fmt.Errorf("failed to write to stdout: %w", err) 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/support/cli/health_command.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/amir20/dozzle/internal/healthcheck" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | type HealthcheckCmd struct{} 15 | 16 | func (h *HealthcheckCmd) Run(args Args, embeddedCerts embed.FS) error { 17 | if matches, err := filepath.Glob("/tmp/agent-*.addr"); err == nil && len(matches) == 1 { 18 | data, err := os.ReadFile(matches[0]) 19 | if err != nil { 20 | return fmt.Errorf("failed to read file: %w", err) 21 | } 22 | agentAddress := string(data) 23 | certs, err := ReadCertificates(embeddedCerts) 24 | if err != nil { 25 | return fmt.Errorf("failed to read certificates: %w", err) 26 | } 27 | ctx, cancel := context.WithTimeout(context.Background(), args.Timeout) 28 | defer cancel() 29 | log.Info().Str("address", agentAddress).Msg("Making RPC request to agent") 30 | return healthcheck.RPCRequest(ctx, agentAddress, certs) 31 | } else { 32 | log.Info().Str("address", args.Addr).Str("base", args.Base).Msg("Making HTTP request to server") 33 | return healthcheck.HttpRequest(args.Addr, args.Base) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/support/cli/logger.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func ConfigureLogger(level string) { 11 | if level, err := zerolog.ParseLevel(level); err == nil { 12 | zerolog.SetGlobalLevel(level) 13 | log.Logger = log.With().Str("version", Version).Logger() 14 | } else { 15 | panic(err) 16 | } 17 | 18 | _, dev := os.LookupEnv("DEV") 19 | 20 | if dev { 21 | writer := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { 22 | w.FieldsOrder = []string{"id", "from", "to", "since"} 23 | }) 24 | log.Logger = log.Output(writer) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/support/cli/valid_env.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func ValidateEnvVars(types ...interface{}) { 12 | expectedEnvs := make(map[string]bool) 13 | for _, t := range types { 14 | typ := reflect.TypeOf(t) 15 | 16 | for i := 0; i < typ.NumField(); i++ { 17 | field := typ.Field(i) 18 | for _, tag := range strings.Split(field.Tag.Get("arg"), ",") { 19 | if strings.HasPrefix(tag, "env:") { 20 | expectedEnvs[strings.TrimPrefix(tag, "env:")] = true 21 | } 22 | } 23 | } 24 | } 25 | 26 | for _, env := range os.Environ() { 27 | actual := strings.Split(env, "=")[0] 28 | if strings.HasPrefix(actual, "DOZZLE_") && !expectedEnvs[actual] { 29 | log.Warn().Str("env", actual).Msg("Unexpected environment variable") 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/support/container/client_service.go: -------------------------------------------------------------------------------- 1 | package container_support 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | 8 | "github.com/amir20/dozzle/internal/container" 9 | ) 10 | 11 | type ContainerFilter = func(*container.Container) bool 12 | 13 | type ClientService interface { 14 | FindContainer(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, error) 15 | ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error) 16 | Host(ctx context.Context) (container.Host, error) 17 | ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error 18 | LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error) 19 | RawLogs(context.Context, container.Container, time.Time, time.Time, container.StdType) (io.ReadCloser, error) 20 | 21 | // Subscriptions 22 | SubscribeStats(context.Context, chan<- container.ContainerStat) 23 | SubscribeEvents(context.Context, chan<- container.ContainerEvent) 24 | SubscribeContainersStarted(context.Context, chan<- container.Container) 25 | 26 | // Blocking streaming functions that should be used in a goroutine 27 | StreamLogs(context.Context, container.Container, time.Time, container.StdType, chan<- *container.LogEvent) error 28 | 29 | // Terminal 30 | Attach(context.Context, container.Container, io.Reader, io.Writer) error 31 | Exec(context.Context, container.Container, []string, io.Reader, io.Writer) error 32 | } 33 | -------------------------------------------------------------------------------- /internal/support/container/container_service.go: -------------------------------------------------------------------------------- 1 | package container_support 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | 8 | "github.com/amir20/dozzle/internal/container" 9 | ) 10 | 11 | type ContainerService struct { 12 | clientService ClientService 13 | Container container.Container 14 | } 15 | 16 | func NewContainerService(clientService ClientService, container container.Container) *ContainerService { 17 | return &ContainerService{ 18 | clientService: clientService, 19 | Container: container, 20 | } 21 | } 22 | 23 | func (c *ContainerService) RawLogs(ctx context.Context, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error) { 24 | return c.clientService.RawLogs(ctx, c.Container, from, to, stdTypes) 25 | } 26 | 27 | func (c *ContainerService) LogsBetweenDates(ctx context.Context, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error) { 28 | return c.clientService.LogsBetweenDates(ctx, c.Container, from, to, stdTypes) 29 | } 30 | 31 | func (c *ContainerService) StreamLogs(ctx context.Context, from time.Time, stdTypes container.StdType, events chan<- *container.LogEvent) error { 32 | return c.clientService.StreamLogs(ctx, c.Container, from, stdTypes, events) 33 | } 34 | 35 | func (c *ContainerService) Action(ctx context.Context, action container.ContainerAction) error { 36 | return c.clientService.ContainerAction(ctx, c.Container, action) 37 | } 38 | 39 | func (c *ContainerService) Attach(ctx context.Context, stdin io.Reader, stdout io.Writer) error { 40 | return c.clientService.Attach(ctx, c.Container, stdin, stdout) 41 | } 42 | 43 | func (c *ContainerService) Exec(ctx context.Context, cmd []string, stdin io.Reader, stdout io.Writer) error { 44 | return c.clientService.Exec(ctx, c.Container, cmd, stdin, stdout) 45 | } 46 | -------------------------------------------------------------------------------- /internal/support/web/search.go: -------------------------------------------------------------------------------- 1 | package support_web 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/amir20/dozzle/internal/container" 8 | ) 9 | 10 | const ( 11 | MarkerStart = "\uE000" 12 | MarkerEnd = "\uE001" 13 | ) 14 | 15 | func ParseRegex(search string) (*regexp.Regexp, error) { 16 | return CreateRegex(search, search == strings.ToLower(search)) 17 | } 18 | 19 | func Search(re *regexp.Regexp, logEvent *container.LogEvent) bool { 20 | matcher := NewPatternMatcher(re, MarkerStart, MarkerEnd) 21 | return matcher.MarkInLogEvent(logEvent) 22 | } 23 | -------------------------------------------------------------------------------- /internal/support/web/url.go: -------------------------------------------------------------------------------- 1 | package support_web 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/amir20/dozzle/internal/container" 7 | ) 8 | 9 | const ( 10 | URLMarkerStart = "\uE002" 11 | URLMarkerEnd = "\uE003" 12 | ) 13 | 14 | // Standard URL regex pattern to match http/https URLs 15 | var urlRegex = regexp.MustCompile(`(https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))`) 16 | 17 | // MarkURLs marks URLs in the logEvent message with special markers 18 | func MarkURLs(logEvent *container.LogEvent) bool { 19 | matcher := NewPatternMatcher(urlRegex, URLMarkerStart, URLMarkerEnd) 20 | return matcher.MarkInLogEvent(logEvent) 21 | } 22 | -------------------------------------------------------------------------------- /internal/utils/ring_buffer.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "sync" 5 | 6 | "encoding/json" 7 | ) 8 | 9 | type RingBuffer[T any] struct { 10 | Size int 11 | data []T 12 | start int 13 | mutex sync.RWMutex 14 | } 15 | 16 | func NewRingBuffer[T any](size int) *RingBuffer[T] { 17 | return &RingBuffer[T]{ 18 | Size: size, 19 | data: make([]T, 0, size), 20 | } 21 | } 22 | 23 | func RingBufferFrom[T any](size int, data []T) *RingBuffer[T] { 24 | if len(data) == 0 { 25 | return NewRingBuffer[T](size) 26 | } 27 | if len(data) > size { 28 | data = data[len(data)-size:] 29 | } 30 | return &RingBuffer[T]{ 31 | Size: size, 32 | data: data, 33 | start: 0, 34 | } 35 | } 36 | 37 | func (r *RingBuffer[T]) Push(data T) { 38 | r.mutex.Lock() 39 | defer r.mutex.Unlock() 40 | if len(r.data) == r.Size { 41 | r.data[r.start] = data 42 | r.start = (r.start + 1) % r.Size 43 | } else { 44 | r.data = append(r.data, data) 45 | } 46 | } 47 | 48 | func (r *RingBuffer[T]) Len() int { 49 | r.mutex.RLock() 50 | defer r.mutex.RUnlock() 51 | return len(r.data) 52 | } 53 | 54 | func (r *RingBuffer[T]) Clear() { 55 | r.mutex.Lock() 56 | defer r.mutex.Unlock() 57 | r.data = r.data[:0] 58 | r.start = 0 59 | } 60 | 61 | func (r *RingBuffer[T]) Data() []T { 62 | r.mutex.RLock() 63 | defer r.mutex.RUnlock() 64 | if len(r.data) == r.Size { 65 | return append(r.data[r.start:], r.data[:r.start]...) 66 | } else { 67 | return r.data 68 | } 69 | } 70 | 71 | func (r *RingBuffer[T]) MarshalJSON() ([]byte, error) { 72 | return json.Marshal(r.Data()) 73 | } 74 | -------------------------------------------------------------------------------- /internal/utils/ring_buffer_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestRingBuffer(t *testing.T) { 9 | rb := NewRingBuffer[int](3) 10 | 11 | rb.Push(1) 12 | rb.Push(2) 13 | rb.Push(3) 14 | 15 | data := rb.Data() 16 | expectedData := []int{1, 2, 3} 17 | if !reflect.DeepEqual(data, expectedData) { 18 | t.Errorf("Expected data to be %v, got %v", expectedData, data) 19 | } 20 | 21 | rb.Push(4) 22 | data = rb.Data() 23 | expectedData = []int{2, 3, 4} 24 | if !reflect.DeepEqual(data, expectedData) { 25 | t.Errorf("Expected data to be %v, got %v", expectedData, data) 26 | } 27 | } 28 | 29 | func TestRingBuffer_MarshalJSON(t *testing.T) { 30 | rb := NewRingBuffer[int](3) 31 | 32 | rb.Push(1) 33 | rb.Push(2) 34 | rb.Push(3) 35 | 36 | data, err := rb.MarshalJSON() 37 | if err != nil { 38 | t.Errorf("Expected error to be nil, got %v", err) 39 | } 40 | 41 | expectedData := []byte("[1,2,3]") 42 | if !reflect.DeepEqual(data, expectedData) { 43 | t.Errorf("Expected data to be %v, got %v", expectedData, data) 44 | } 45 | } 46 | 47 | func TestRingBuffer_Clear(t *testing.T) { 48 | rb := NewRingBuffer[int](3) 49 | 50 | rb.Push(1) 51 | rb.Push(2) 52 | rb.Push(3) 53 | 54 | rb.Clear() 55 | data := rb.Data() 56 | expectedData := []int{} 57 | if !reflect.DeepEqual(data, expectedData) { 58 | t.Errorf("Expected data to be %v, got %v", expectedData, data) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/utils/time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "time" 4 | 5 | func Min(a, b time.Time) time.Time { 6 | if a.Before(b) { 7 | return a 8 | } 9 | return b 10 | } 11 | 12 | func Max(a, b time.Time) time.Time { 13 | if a.After(b) { 14 | return a 15 | } 16 | return b 17 | } 18 | -------------------------------------------------------------------------------- /internal/web/actions.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/amir20/dozzle/internal/auth" 7 | "github.com/amir20/dozzle/internal/container" 8 | "github.com/go-chi/chi/v5" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | func (h *handler) containerActions(w http.ResponseWriter, r *http.Request) { 13 | action := chi.URLParam(r, "action") 14 | id := chi.URLParam(r, "id") 15 | 16 | userLabels := h.config.Labels 17 | if h.config.Authorization.Provider != NONE { 18 | user := auth.UserFromContext(r.Context()) 19 | if user.ContainerLabels.Exists() { 20 | userLabels = user.ContainerLabels 21 | } 22 | } 23 | 24 | containerService, err := h.hostService.FindContainer(hostKey(r), id, userLabels) 25 | if err != nil { 26 | log.Error().Err(err).Msg("error while trying to find container") 27 | http.Error(w, err.Error(), http.StatusNotFound) 28 | return 29 | } 30 | 31 | parsedAction, err := container.ParseContainerAction(action) 32 | if err != nil { 33 | log.Error().Err(err).Msg("error while trying to parse action") 34 | http.Error(w, err.Error(), http.StatusBadRequest) 35 | return 36 | } 37 | 38 | if err := containerService.Action(r.Context(), parsedAction); err != nil { 39 | log.Error().Err(err).Msg("error while trying to perform container action") 40 | http.Error(w, err.Error(), http.StatusInternalServerError) 41 | return 42 | } 43 | 44 | log.Info().Str("action", action).Str("container", containerService.Container.Name).Msg("container action performed") 45 | http.Error(w, "", http.StatusNoContent) 46 | } 47 | -------------------------------------------------------------------------------- /internal/web/auth.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func (h *handler) createToken(w http.ResponseWriter, r *http.Request) { 11 | user := r.PostFormValue("username") 12 | pass := r.PostFormValue("password") 13 | 14 | if token, err := h.config.Authorization.Authorizer.CreateToken(user, pass); err == nil { 15 | expires := time.Time{} 16 | if h.config.Authorization.TTL > 0 { 17 | expires = time.Now().Add(h.config.Authorization.TTL) 18 | } 19 | 20 | http.SetCookie(w, &http.Cookie{ 21 | Name: "jwt", 22 | Value: token, 23 | HttpOnly: true, 24 | Path: "/", 25 | SameSite: http.SameSiteLaxMode, 26 | Expires: expires, 27 | }) 28 | log.Info().Str("user", user).Msg("Token created") 29 | w.WriteHeader(http.StatusOK) 30 | w.Write([]byte(http.StatusText(http.StatusOK))) 31 | } else { 32 | log.Error().Err(err).Msg("Failed to create token") 33 | http.Error(w, err.Error(), http.StatusUnauthorized) 34 | } 35 | } 36 | 37 | func (h *handler) deleteToken(w http.ResponseWriter, r *http.Request) { 38 | http.SetCookie(w, &http.Cookie{ 39 | Name: "jwt", 40 | Value: "", 41 | HttpOnly: true, 42 | Path: "/", 43 | SameSite: http.SameSiteLaxMode, 44 | Expires: time.Unix(0, 0), 45 | }) 46 | w.WriteHeader(http.StatusOK) 47 | w.Write([]byte(http.StatusText(http.StatusOK))) 48 | } 49 | -------------------------------------------------------------------------------- /internal/web/auth_proxy_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | 7 | "testing" 8 | 9 | "github.com/amir20/dozzle/internal/auth" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | func Test_createRoutes_proxy_missing_headers(t *testing.T) { 17 | fs := afero.NewMemMapFs() 18 | require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.") 19 | 20 | handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/", 21 | Authorization: Authorization{ 22 | Provider: FORWARD_PROXY, 23 | Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter"), 24 | }, 25 | }) 26 | req, err := http.NewRequest("GET", "/", nil) 27 | require.NoError(t, err, "NewRequest should not return an error.") 28 | rr := httptest.NewRecorder() 29 | 30 | handler.ServeHTTP(rr, req) 31 | 32 | assert.Equal(t, 401, rr.Code, "Response code should be 401.") 33 | } 34 | 35 | func Test_createRoutes_proxy_happy(t *testing.T) { 36 | fs := afero.NewMemMapFs() 37 | require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.") 38 | 39 | handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/", 40 | Authorization: Authorization{ 41 | Provider: FORWARD_PROXY, 42 | Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter"), 43 | }, 44 | }) 45 | req, err := http.NewRequest("GET", "/", nil) 46 | req.Header.Set("Remote-Email", "amir@test.com") 47 | req.Header.Set("Remote-Name", "Amir") 48 | req.Header.Set("Remote-User", "amir") 49 | require.NoError(t, err, "NewRequest should not return an error.") 50 | rr := httptest.NewRecorder() 51 | 52 | handler.ServeHTTP(rr, req) 53 | assert.Equal(t, 200, rr.Code, "Response code should be 200.") 54 | } 55 | -------------------------------------------------------------------------------- /internal/web/brotli.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/andybalholm/brotli" 9 | ) 10 | 11 | type brotliWriter struct { 12 | io.Writer 13 | http.ResponseWriter 14 | } 15 | 16 | func (w brotliWriter) Write(b []byte) (int, error) { 17 | return w.Writer.Write(b) 18 | } 19 | 20 | func Brotli(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | // Check if client supports Brotli 23 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "br") { 24 | next.ServeHTTP(w, r) 25 | return 26 | } 27 | 28 | w.Header().Set("Content-Encoding", "br") 29 | w.Header().Add("Vary", "Accept-Encoding") 30 | 31 | brWriter := brotli.NewWriter(w) 32 | defer brWriter.Close() 33 | 34 | bw := brotliWriter{Writer: brWriter, ResponseWriter: w} 35 | next.ServeHTTP(bw, r) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /internal/web/csp.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func cspHeaders(next http.Handler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | w.Header().Set( 10 | "Content-Security-Policy", 11 | "default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data:;", 12 | ) 13 | next.ServeHTTP(w, r) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /internal/web/debug.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/amir20/dozzle/internal/container" 8 | ) 9 | 10 | func (h *handler) debugStore(w http.ResponseWriter, r *http.Request) { 11 | respone := make(map[string]interface{}) 12 | respone["hosts"] = h.hostService.Hosts() 13 | containers, errors := h.hostService.ListAllContainers(container.ContainerLabels{}) 14 | respone["containers"] = containers 15 | respone["errors"] = errors 16 | 17 | w.Header().Set("Content-Type", "application/json") 18 | w.WriteHeader(http.StatusOK) 19 | json.NewEncoder(w).Encode(respone) 20 | } 21 | -------------------------------------------------------------------------------- /internal/web/download_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "time" 7 | 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/amir20/dozzle/internal/container" 13 | "github.com/stretchr/testify/mock" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func Test_handler_download_logs(t *testing.T) { 18 | id := "123456" 19 | req, err := http.NewRequest("GET", "/api/containers/localhost~"+id+"/download?stdout=1", nil) 20 | require.NoError(t, err, "NewRequest should not return an error.") 21 | 22 | mockedClient := new(MockedClient) 23 | 24 | data := makeMessage("INFO Testing logs...", container.STDOUT) 25 | 26 | mockedClient.On("FindContainer", mock.Anything, id).Return(container.Container{ID: id, Tty: false}, nil) 27 | mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, mock.Anything, mock.Anything, container.STDOUT).Return(io.NopCloser(bytes.NewReader(data)), nil) 28 | mockedClient.On("Host").Return(container.Host{ 29 | ID: "localhost", 30 | }) 31 | mockedClient.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).Run(func(args mock.Arguments) { 32 | time.Sleep(1 * time.Second) 33 | }) 34 | mockedClient.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{ 35 | {ID: id, Name: "test", State: "running"}, 36 | }, nil) 37 | 38 | handler := createDefaultHandler(mockedClient) 39 | rr := httptest.NewRecorder() 40 | handler.ServeHTTP(rr, req) 41 | require.Equal(t, http.StatusOK, rr.Code, "Status code should be 200.") 42 | mockedClient.AssertExpectations(t) 43 | } 44 | -------------------------------------------------------------------------------- /internal/web/healthcheck.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func (h *handler) healthcheck(w http.ResponseWriter, r *http.Request) { 10 | log.Debug().Msg("Executing healthcheck") 11 | 12 | clients := h.hostService.LocalClients() 13 | for _, client := range clients { 14 | if err := client.Ping(r.Context()); err != nil { 15 | log.Error().Err(err).Str("host", client.Host().Name).Msg("error pinging host") 16 | w.WriteHeader(http.StatusInternalServerError) 17 | } 18 | } 19 | 20 | w.WriteHeader(http.StatusOK) 21 | } 22 | -------------------------------------------------------------------------------- /internal/web/profile.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/amir20/dozzle/internal/auth" 8 | "github.com/amir20/dozzle/internal/profile" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | func (h *handler) updateProfile(w http.ResponseWriter, r *http.Request) { 13 | user := auth.UserFromContext(r.Context()) 14 | if user == nil { 15 | http.Error(w, "Unable to find user", http.StatusInternalServerError) 16 | return 17 | } 18 | 19 | if err := profile.UpdateFromReader(*user, r.Body); err != nil { 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | log.Error().Err(err).Msg("Failed to update profile") 22 | return 23 | } 24 | 25 | w.WriteHeader(http.StatusOK) 26 | } 27 | 28 | func (h *handler) avatar(w http.ResponseWriter, r *http.Request) { 29 | user := auth.UserFromContext(r.Context()) 30 | if user == nil { 31 | http.Error(w, "Unable to find user", http.StatusInternalServerError) 32 | return 33 | } 34 | 35 | url := user.AvatarURL() 36 | 37 | if url == "" { 38 | http.Error(w, "Unable to find avatar", http.StatusNotFound) 39 | return 40 | } 41 | 42 | log.Trace().Str("url", url).Msg("Fetching avatar") 43 | response, err := http.Get(url) 44 | if err != nil { 45 | http.Error(w, err.Error(), http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | defer response.Body.Close() 50 | 51 | if response.StatusCode != http.StatusOK { 52 | log.Error().Str("url", url).Int("status", response.StatusCode).Msg("Failed to fetch avatar") 53 | return 54 | } 55 | 56 | w.Header().Set("Content-Type", response.Header.Get("Content-Type")) 57 | io.Copy(w, response.Body) 58 | } 59 | -------------------------------------------------------------------------------- /internal/web/releases.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/amir20/dozzle/internal/cache" 9 | "github.com/amir20/dozzle/internal/releases" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | var cachedReleases *cache.Cache[[]releases.Release] 14 | 15 | func (h *handler) releases(w http.ResponseWriter, r *http.Request) { 16 | if cachedReleases == nil { 17 | cachedReleases = cache.New(func() ([]releases.Release, error) { 18 | return releases.Fetch(h.config.Version) 19 | }, time.Hour) 20 | } 21 | releases, err, hit := cachedReleases.GetWithHit() 22 | 23 | if err != nil { 24 | http.Error(w, err.Error(), http.StatusInternalServerError) 25 | log.Debug().Err(err).Msg("error fetching releases") 26 | return 27 | } 28 | 29 | w.Header().Set("Content-Type", "application/json") 30 | if hit { 31 | w.Header().Set("X-Cache", "HIT") 32 | } 33 | 34 | if err := json.NewEncoder(w).Encode(releases); err != nil { 35 | log.Error().Err(err).Msg("error encoding releases") 36 | http.Error(w, err.Error(), http.StatusInternalServerError) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/web/version.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (h *handler) version(w http.ResponseWriter, r *http.Request) { 9 | w.Header().Add("Content-Type", "text/html") 10 | fmt.Fprintf(w, "
%v
", h.config.Version) 11 | } 12 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: "./e2e", 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: "html", 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | // baseURL: 'http://127.0.0.1:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: "on-first-retry", 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: "chromium", 37 | use: { ...devices["Desktop Chrome"] }, 38 | }, 39 | 40 | { 41 | name: "Mobile Chrome", 42 | use: { ...devices["Pixel 5"] }, 43 | testMatch: "**/visual.spec.ts", 44 | }, 45 | 46 | /* Test against branded browsers. */ 47 | // { 48 | // name: 'Microsoft Edge', 49 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 50 | // }, 51 | // { 52 | // name: 'Google Chrome', 53 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 54 | // }, 55 | ], 56 | // webServer: { 57 | // command: "docker compose up", 58 | // url: "http://127.0.0.1:7070", 59 | // reuseExistingServer: !process.env.CI, 60 | // }, 61 | }); 62 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - docs 3 | - e2e 4 | overrides: 5 | esbuild@<=0.24.2: '>=0.25.0' 6 | -------------------------------------------------------------------------------- /protos/types.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option go_package = "internal/agent/pb"; 3 | 4 | package protobuf; 5 | 6 | import "google/protobuf/timestamp.proto"; 7 | import "google/protobuf/any.proto"; 8 | 9 | message Container { 10 | string id = 1; 11 | string name = 2; 12 | string image = 3; 13 | string status = 4; // deprecated 14 | string state = 5; 15 | string ImageId = 6; // deprecated 16 | google.protobuf.Timestamp created = 7; 17 | google.protobuf.Timestamp started = 8; 18 | string health = 9; 19 | string host = 10; 20 | bool tty = 11; 21 | map labels = 12; 22 | repeated ContainerStat stats = 13; 23 | string group = 14; 24 | string command = 15; 25 | google.protobuf.Timestamp finished = 16; 26 | uint64 memoryLimit = 17; 27 | double cpuLimit = 18; 28 | bool fullyLoaded = 19; 29 | } 30 | 31 | message ContainerStat { 32 | string id = 1; 33 | double cpuPercent = 2; 34 | double memoryUsage = 3; 35 | double memoryPercent = 4; 36 | } 37 | 38 | message LogEvent { 39 | uint32 id = 1; 40 | string containerId = 2; 41 | google.protobuf.Any message = 3; 42 | google.protobuf.Timestamp timestamp = 4; 43 | string level = 5; 44 | string stream = 6; 45 | string position = 7; 46 | string rawMessage = 8; 47 | } 48 | 49 | message SimpleMessage { string message = 1; } 50 | 51 | message ComplexMessage { bytes data = 1; } 52 | 53 | message ContainerEvent { 54 | string actorId = 1; 55 | string name = 2; 56 | string host = 3; 57 | google.protobuf.Timestamp timestamp = 4; 58 | } 59 | 60 | message Host { 61 | string id = 1; 62 | string name = 2; 63 | string nodeAddress = 3; 64 | bool swarm = 4; 65 | map labels = 5; 66 | string operatingSystem = 6; 67 | string osVersion = 7; 68 | string osType = 8; 69 | uint32 cpuCores = 9; 70 | uint64 memory = 10; 71 | string agentVersion = 11; 72 | string dockerVersion = 12; 73 | } 74 | 75 | enum ContainerAction { 76 | Start = 0; 77 | Stop = 1; 78 | Restart = 2; 79 | } 80 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir20/dozzle/7e5b413c4815b4d10e2188958c79ab2975c0cf4a/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Dozzle 10 | 13 | 14 | {{- if .Dev}} 15 | 16 | 17 | {{- else }} {{ $js := index .Manifest "assets/main.ts" "file" }} {{ $css := index .Manifest "assets/main.ts" "css" 18 | }} 19 | 20 | 21 | {{- end }} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dozzle", 3 | "short_name": "Dozzle", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "lang": "en", 7 | "scope": "/", 8 | "description": "A log viewer for containers", 9 | "icons": [{ "src": "/apple-touch-icon.png", "sizes": "512x512", "type": "image/png" }] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "incremental": false, 10 | "skipLibCheck": true, 11 | "moduleResolution": "Bundler", 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": true, 14 | "strictNullChecks": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "paths": { 17 | "@/*": ["assets/*"] 18 | }, 19 | "jsx": "preserve", 20 | "types": [ 21 | "vitest", 22 | "vite/client", 23 | "vite-plugin-vue-layouts/client", 24 | "unplugin-vue-macros/macros-global", 25 | "unplugin-vue-router/client" 26 | ] 27 | }, 28 | 29 | "exclude": ["dist", "node_modules", "e2e"] 30 | } 31 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type BeaconEvent struct { 4 | Name string `json:"name"` 5 | Version string `json:"version"` 6 | Browser string `json:"browser"` 7 | AuthProvider string `json:"authProvider"` 8 | FilterLength int `json:"filterLength"` 9 | Clients int `json:"clients"` 10 | HasCustomAddress bool `json:"hasCustomAddress"` 11 | HasCustomBase bool `json:"hasCustomBase"` 12 | HasHostname bool `json:"hasHostname"` 13 | RunningContainers int `json:"runningContainers"` 14 | HasActions bool `json:"hasActions"` 15 | HasShell bool `json:"hasShell"` 16 | IsSwarmMode bool `json:"isSwarmMode"` 17 | ServerVersion string `json:"serverVersion"` 18 | ServerID string `json:"serverID"` 19 | Mode string `json:"mode"` 20 | RemoteAgents int `json:"remoteAgents"` 21 | RemoteClients int `json:"remoteClients"` 22 | SubCommand string `json:"subCommand"` 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vite"; 3 | import Vue from "@vitejs/plugin-vue"; 4 | import VueMacros from "unplugin-vue-macros/vite"; 5 | import Icons from "unplugin-icons/vite"; 6 | import Components from "unplugin-vue-components/vite"; 7 | import AutoImport from "unplugin-auto-import/vite"; 8 | import IconsResolver from "unplugin-icons/resolver"; 9 | import VueRouter from "unplugin-vue-router/vite"; 10 | import Layouts from "vite-plugin-vue-layouts"; 11 | import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"; 12 | import { VueRouterAutoImports } from "unplugin-vue-router"; 13 | import svgLoader from "vite-svg-loader"; 14 | import tailwindcss from "@tailwindcss/vite"; 15 | 16 | export default defineConfig(() => ({ 17 | resolve: { 18 | alias: { 19 | "@/": `${path.resolve(__dirname, "assets")}/`, 20 | }, 21 | }, 22 | build: { 23 | manifest: true, 24 | rollupOptions: { 25 | input: "assets/main.ts", 26 | }, 27 | modulePreload: { 28 | polyfill: false, 29 | }, 30 | target: "esnext", 31 | }, 32 | plugins: [ 33 | VueRouter({ 34 | routesFolder: { 35 | src: "./assets/pages", 36 | }, 37 | dts: "./assets/typed-router.d.ts", 38 | importMode: "sync", 39 | }), 40 | VueMacros({ 41 | plugins: { 42 | vue: Vue(), 43 | }, 44 | }), 45 | Icons({ 46 | autoInstall: true, 47 | }), 48 | 49 | Layouts({ 50 | layoutsDirs: "assets/layouts", 51 | }), 52 | Components({ 53 | dirs: ["assets/components"], 54 | resolvers: [ 55 | IconsResolver({ 56 | componentPrefix: "", 57 | }), 58 | ], 59 | 60 | dts: "assets/components.d.ts", 61 | }), 62 | AutoImport({ 63 | imports: ["vue", VueRouterAutoImports, "vue-i18n", "pinia", "@vueuse/head", "@vueuse/core"], 64 | dts: "assets/auto-imports.d.ts", 65 | dirs: ["assets/composable", "assets/stores", "assets/utils"], 66 | vueTemplate: true, 67 | }), 68 | VueI18nPlugin({ 69 | runtimeOnly: true, 70 | strictMessage: false, 71 | include: [path.resolve(__dirname, "locales/**")], 72 | }), 73 | svgLoader({}), 74 | tailwindcss(), 75 | ], 76 | test: { 77 | include: ["assets/**/*.spec.ts"], 78 | }, 79 | })); 80 | --------------------------------------------------------------------------------