Compare commits
	
		
			111 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					b92de8a508 | ||
| 
						 | 
					957a5104b8 | ||
| 
						 | 
					a4981f1b2c | ||
| 
						 | 
					beaeecd457 | ||
| 
						 | 
					dcc9088e31 | ||
| 
						 | 
					27e8129caa | ||
| 
						 | 
					7d801379db | ||
| 
						 | 
					420da8c363 | ||
| 
						 | 
					917384a2d9 | ||
| 
						 | 
					e97e69a0e1 | ||
| 
						 | 
					87d51409ff | ||
| 
						 | 
					551b1f04c7 | ||
| 
						 | 
					a35f4ef32e | ||
| 
						 | 
					441f234398 | ||
| 
						 | 
					dc452e2847 | ||
| 
						 | 
					b9ee28ca8d | ||
| 
						 | 
					474ce714db | ||
| 
						 | 
					988afbe1c0 | ||
| 
						 | 
					f9a0d4e881 | ||
| 
						 | 
					78dd5b1d8b | ||
| 
						 | 
					ede15194a1 | ||
| 
						 | 
					88838ec63d | ||
| 
						 | 
					66b902a5e0 | ||
| 
						 | 
					e4d4d5251b | ||
| 
						 | 
					0c50ff7c91 | ||
| 
						 | 
					427edaa1ef | ||
| 
						 | 
					b8c82af838 | ||
| 
						 | 
					57ad1b98ff | ||
| 
						 | 
					9d2bdb6a53 | ||
| 
						 | 
					d97f7c5c6f | ||
| 
						 | 
					abd334f3b8 | ||
| 
						 | 
					33de8a4f07 | ||
| 
						 | 
					93cfd0e597 | ||
| 
						 | 
					537f7c0a01 | ||
| 
						 | 
					e114f877c1 | ||
| 
						 | 
					55bc51c9c2 | ||
| 
						 | 
					d93b662907 | ||
| 
						 | 
					3eec6cdd14 | ||
| 
						 | 
					3b9adf8260 | ||
| 
						 | 
					dde707a97a | ||
| 
						 | 
					50ccf6311b | ||
| 
						 | 
					705a339e49 | ||
| 
						 | 
					f81c240a47 | ||
| 
						 | 
					262095e5bb | ||
| 
						 | 
					ba900c4374 | ||
| 
						 | 
					9dc6b3790d | ||
| 
						 | 
					b96785f2be | ||
| 
						 | 
					dd6f4b1e31 | ||
| 
						 | 
					651291ecad | ||
| 
						 | 
					a5bcec68cb | ||
| 
						 | 
					77d6a22122 | ||
| 
						 | 
					40f97073e8 | ||
| 
						 | 
					75339ffba1 | ||
| 
						 | 
					2fdfba5a42 | ||
| 
						 | 
					5987330cdc | ||
| 
						 | 
					14c7c21f9f | ||
| 
						 | 
					fc9fdaf8b6 | ||
| 
						 | 
					5979a6d0e5 | ||
| 
						 | 
					ca2c46ffce | ||
| 
						 | 
					1cc7e92466 | ||
| 
						 | 
					cfc3e81820 | ||
| 
						 | 
					67ab2ab170 | ||
| 
						 | 
					e1ce378421 | ||
| 
						 | 
					f083ea028d | ||
| 
						 | 
					063a82198c | ||
| 
						 | 
					d03c3440de | ||
| 
						 | 
					f7b28ad1e0 | ||
| 
						 | 
					52a95757ce | ||
| 
						 | 
					efc725dadc | ||
| 
						 | 
					ff8c539829 | ||
| 
						 | 
					875e17717e | ||
| 
						 | 
					929f8c19f8 | ||
| 
						 | 
					bdcc856071 | ||
| 
						 | 
					2325881bd8 | ||
| 
						 | 
					067fea2b7a | ||
| 
						 | 
					8ac689ca57 | ||
| 
						 | 
					5e9ffe7fcf | ||
| 
						 | 
					3b3ba92d27 | ||
| 
						 | 
					df2834fd81 | ||
| 
						 | 
					2ecfefb35f | ||
| 
						 | 
					d18d3f800b | ||
| 
						 | 
					9d7fd4eaf0 | ||
| 
						 | 
					032ebfd307 | ||
| 
						 | 
					7f74a0f551 | ||
| 
						 | 
					dc42180339 | ||
| 
						 | 
					972cbb8b2e | ||
| 
						 | 
					5ee895357d | ||
| 
						 | 
					d7cfe64273 | ||
| 
						 | 
					f06354f909 | ||
| 
						 | 
					0416fd541c | ||
| 
						 | 
					98701b1c7c | ||
| 
						 | 
					17e08c02bb | ||
| 
						 | 
					22108a2782 | ||
| 
						 | 
					742056bbef | ||
| 
						 | 
					2556dd07b3 | ||
| 
						 | 
					e43879b69c | ||
| 
						 | 
					239bd874b2 | ||
| 
						 | 
					c9b8b3f95a | ||
| 
						 | 
					3454e907d3 | ||
| 
						 | 
					d33376e03b | ||
| 
						 | 
					f045f8bc95 | ||
| 
						 | 
					3e5f174f6e | ||
| 
						 | 
					34a21463b5 | ||
| 
						 | 
					02fc893d4b | ||
| 
						 | 
					7c342e17a1 | ||
| 
						 | 
					8e23f61220 | ||
| 
						 | 
					4a65020cbf | ||
| 
						 | 
					f59aa6bfaa | ||
| 
						 | 
					ef78f94cb5 | ||
| 
						 | 
					1ed4dde60e | ||
| 
						 | 
					959966d3eb | 
							
								
								
									
										12
									
								
								.babelrc
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								.babelrc
									
									
									
									
									
								
							@@ -1,10 +1,16 @@
 | 
			
		||||
{
 | 
			
		||||
  "presets": ["env"],
 | 
			
		||||
  "presets": [
 | 
			
		||||
    [
 | 
			
		||||
      "@babel/preset-env",
 | 
			
		||||
      {
 | 
			
		||||
        "modules": false
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  ],
 | 
			
		||||
  "plugins": [
 | 
			
		||||
    [
 | 
			
		||||
      "transform-runtime",
 | 
			
		||||
      "@babel/plugin-transform-runtime",
 | 
			
		||||
      {
 | 
			
		||||
        "polyfill": false,
 | 
			
		||||
        "regenerator": true
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
end_of_line = lf
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
trim_trailing_whitespace = true
 | 
			
		||||
max_line_length = 120
 | 
			
		||||
 | 
			
		||||
[*.go]
 | 
			
		||||
indent_style = tab
 | 
			
		||||
indent_size = 4
 | 
			
		||||
 | 
			
		||||
[package.json]
 | 
			
		||||
indent_size = 1
 | 
			
		||||
							
								
								
									
										1
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
*.snapshot binary
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -4,4 +4,5 @@ node_modules
 | 
			
		||||
.cache
 | 
			
		||||
static
 | 
			
		||||
a_main-packr.go
 | 
			
		||||
dozzle
 | 
			
		||||
dozzle
 | 
			
		||||
gin-bin
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
before:
 | 
			
		||||
  hooks:
 | 
			
		||||
    - npm run clean
 | 
			
		||||
    - npm run build
 | 
			
		||||
    - packr
 | 
			
		||||
builds:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
{
 | 
			
		||||
  "printWidth": 120
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
language: go
 | 
			
		||||
 | 
			
		||||
go:
 | 
			
		||||
  - "1.11"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  - docker
 | 
			
		||||
 | 
			
		||||
before_install:
 | 
			
		||||
  - nvm install --lts
 | 
			
		||||
  - npm i -g npm
 | 
			
		||||
  - npm ci
 | 
			
		||||
  - go get -u github.com/gobuffalo/packr/packr
 | 
			
		||||
 | 
			
		||||
after_success:
 | 
			
		||||
  # docker login is required if you want to push docker images.
 | 
			
		||||
  # DOCKER_PASSWORD should be a secret in your .travis.yml configuration.
 | 
			
		||||
  # - test -n "$TRAVIS_TAG" && docker login -u=myuser -p="$DOCKER_PASSWORD"
 | 
			
		||||
 | 
			
		||||
deploy:
 | 
			
		||||
  - provider: script
 | 
			
		||||
    skip_cleanup: true
 | 
			
		||||
    script: curl -sL https://git.io/goreleaser | bash
 | 
			
		||||
    on:
 | 
			
		||||
      tags: true
 | 
			
		||||
      condition: $TRAVIS_OS_NAME = linux
 | 
			
		||||
							
								
								
									
										59
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
			
		||||
[](https://goreportcard.com/report/github.com/amir20/dozzle)
 | 
			
		||||
 | 
			
		||||
# dozzle
 | 
			
		||||
 | 
			
		||||
Dozzle is a log viewer for Docker. It's free. It's small. And it's right in your browser. Oh, did I mention it is also real-time?
 | 
			
		||||
 | 
			
		||||
While dozzle should work for most, it is not meant to be a full logging solution. For enterprise use, I recommend you look at [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana).
 | 
			
		||||
 | 
			
		||||
But if you don't want to pay for those services, then you are in luck! Dozzle will be able to capture all logs from your containers and send them in real-time to your browser. Installation is also very easy.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Getting dozzle
 | 
			
		||||
 | 
			
		||||
Dozzle is a very small Docker container (4 MB compressed). Pull the latest release from the index:
 | 
			
		||||
 | 
			
		||||
    $ docker pull amir20/dozzle:latest
 | 
			
		||||
 | 
			
		||||
## Using dozzle
 | 
			
		||||
 | 
			
		||||
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `-volume` to `/var/run/docker.sock`:
 | 
			
		||||
 | 
			
		||||
    $ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:8080 amir20/dozzle:latest
 | 
			
		||||
 | 
			
		||||
dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`.
 | 
			
		||||
 | 
			
		||||
#### Security
 | 
			
		||||
 | 
			
		||||
dozzle doesn't support authentication out of the box. You can control the device dozzle binds to by passing `--addr` parameter. For example,
 | 
			
		||||
 | 
			
		||||
    $ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:1224 amir20/dozzle:latest --addr localhost:1224
 | 
			
		||||
 | 
			
		||||
will bind to `localhost` on port `1224`. You can then use a reverse proxy to control who can see dozzle.
 | 
			
		||||
 | 
			
		||||
#### Changing base URL
 | 
			
		||||
 | 
			
		||||
dozzle by default mounts to "/". If you want to control the base path you can use the `--base` option. For example, if you want to mount at "/foobar",
 | 
			
		||||
then you can override by using `--base /foobar`.
 | 
			
		||||
 | 
			
		||||
    $ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest --base /foobar
 | 
			
		||||
 | 
			
		||||
dozzle will be available at [http://localhost:8080/foobar/](http://localhost:8080/foobar/).
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#### Environment variable, DOCKER_API_VERSION
 | 
			
		||||
 | 
			
		||||
If you see
 | 
			
		||||
 | 
			
		||||
    2018/10/31 08:53:17 Error response from daemon: client version 1.40 is too new. Maximum supported API version is 1.38
 | 
			
		||||
 | 
			
		||||
Then you need to modify `DOCKER_API_VERSION` to let dozzle know which version of the API is supported. By default, `DOCKER_API_VERSION=1.38` and you can change it by passing `-e` flag. For example, this would change the `DOCKER_API_VERSION` to `1.20`
 | 
			
		||||
 | 
			
		||||
    $ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -e DOCKER_API_VERSION=1.20 -p 8888:8080 amir20/dozzle:latest
 | 
			
		||||
 | 
			
		||||
If you are not sure what to set `DOCKER_API_VERSION` then run `docker version` which will show supported API version.
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
[MIT](LICENSE)
 | 
			
		||||
							
								
								
									
										46
									
								
								__snapshots__/dozzle.snapshot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								__snapshots__/dozzle.snapshot
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
/* snapshot: Test_handler_listContainers_happy */
 | 
			
		||||
HTTP/1.1 200 OK
 | 
			
		||||
Connection: close
 | 
			
		||||
Content-Type: text/plain; charset=utf-8
 | 
			
		||||
 | 
			
		||||
[{"id":"1234567890","names":null,"name":"test","image":"image","imageId":"image_id","command":"command","created":0,"state":"state","status":"status"}]
 | 
			
		||||
 | 
			
		||||
/* snapshot: Test_handler_streamEvents_error */
 | 
			
		||||
HTTP/1.1 200 OK
 | 
			
		||||
Connection: close
 | 
			
		||||
Cache-Control: no-cache
 | 
			
		||||
Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
 | 
			
		||||
/* snapshot: Test_handler_streamEvents_error_request */
 | 
			
		||||
HTTP/1.1 200 OK
 | 
			
		||||
Connection: close
 | 
			
		||||
Cache-Control: no-cache
 | 
			
		||||
Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
 | 
			
		||||
/* snapshot: Test_handler_streamEvents_happy */
 | 
			
		||||
HTTP/1.1 200 OK
 | 
			
		||||
Connection: close
 | 
			
		||||
Cache-Control: no-cache
 | 
			
		||||
Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
 | 
			
		||||
event: containers-changed
 | 
			
		||||
data: start
 | 
			
		||||
 | 
			
		||||
/* snapshot: Test_handler_streamLogs_error_reading */
 | 
			
		||||
HTTP/1.1 200 OK
 | 
			
		||||
Connection: close
 | 
			
		||||
Cache-Control: no-cache
 | 
			
		||||
Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
 | 
			
		||||
/* snapshot: Test_handler_streamLogs_happy */
 | 
			
		||||
HTTP/1.1 200 OK
 | 
			
		||||
Connection: close
 | 
			
		||||
Cache-Control: no-cache
 | 
			
		||||
Connection: keep-alive
 | 
			
		||||
Content-Type: text/event-stream
 | 
			
		||||
 | 
			
		||||
data: INFO Testing logs...
 | 
			
		||||
							
								
								
									
										101
									
								
								assets/App.vue
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								assets/App.vue
									
									
									
									
									
								
							@@ -1,15 +1,102 @@
 | 
			
		||||
<template lang="html">
 | 
			
		||||
    <router-view></router-view>
 | 
			
		||||
  <div class="columns is-marginless">
 | 
			
		||||
    <aside class="column menu is-2-desktop is-3-tablet">
 | 
			
		||||
      <a
 | 
			
		||||
        role="button"
 | 
			
		||||
        class="navbar-burger burger is-white is-hidden-tablet is-pulled-right"
 | 
			
		||||
        @click="showNav = !showNav;"
 | 
			
		||||
        :class="{ 'is-active': showNav }"
 | 
			
		||||
      >
 | 
			
		||||
        <span></span> <span></span> <span></span>
 | 
			
		||||
      </a>
 | 
			
		||||
      <h1 class="title has-text-warning is-marginless">Dozzle</h1>
 | 
			
		||||
      <p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">Containers</p>
 | 
			
		||||
      <ul class="menu-list is-hidden-mobile" :class="{ 'is-active': showNav }">
 | 
			
		||||
        <li v-for="item in containers">
 | 
			
		||||
          <router-link :to="{ name: 'container', params: { id: item.id, name: item.name } }" active-class="is-active">
 | 
			
		||||
            <div class="hide-overflow">{{ item.name }}</div>
 | 
			
		||||
          </router-link>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </aside>
 | 
			
		||||
    <div class="column is-offset-2-desktop is-offset-3-tablet"><router-view></router-view></div>
 | 
			
		||||
    <vue-headful :title="title" />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
    export default {
 | 
			
		||||
        name: "App"
 | 
			
		||||
let es;
 | 
			
		||||
export default {
 | 
			
		||||
  name: "App",
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      title: "Dozzle",
 | 
			
		||||
      containers: [],
 | 
			
		||||
      showNav: false
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  async created() {
 | 
			
		||||
    await this.fetchContainerList();
 | 
			
		||||
    this.title = `${this.containers.length} containers - Dozzle`;
 | 
			
		||||
    es = new EventSource(`${BASE_PATH}/api/events/stream`);
 | 
			
		||||
    es.addEventListener("containers-changed", e => setTimeout(this.fetchContainerList, 1000), false);
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {
 | 
			
		||||
    if (es) {
 | 
			
		||||
      es.close();
 | 
			
		||||
      es = null;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    async fetchContainerList() {
 | 
			
		||||
      this.containers = await (await fetch(`${BASE_PATH}/api/containers.json`)).json();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="css">
 | 
			
		||||
    .section.is-fullwidth {
 | 
			
		||||
        padding: 0 !important;
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
.is-hidden-mobile.is-active {
 | 
			
		||||
  display: block !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navbar-burger {
 | 
			
		||||
  height: 2.35rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
aside {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  z-index: 2;
 | 
			
		||||
  padding: 1em;
 | 
			
		||||
 | 
			
		||||
  @media screen and (min-width: 769px) {
 | 
			
		||||
    & {
 | 
			
		||||
      height: 100vh;
 | 
			
		||||
      overflow: auto;
 | 
			
		||||
    }
 | 
			
		||||
</style>
 | 
			
		||||
  }
 | 
			
		||||
  @media screen and (max-width: 768px) {
 | 
			
		||||
    & {
 | 
			
		||||
      position: sticky;
 | 
			
		||||
      top: 0;
 | 
			
		||||
      left: 0;
 | 
			
		||||
      right: 0;
 | 
			
		||||
      background: #222;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .menu-label {
 | 
			
		||||
      margin-top: 1em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hide-overflow {
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.burger.is-white {
 | 
			
		||||
  color: #fff;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										71
									
								
								assets/components/ScrollbarNotification.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								assets/components/ScrollbarNotification.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
<template lang="html">
 | 
			
		||||
  <transition name="fade">
 | 
			
		||||
    <button
 | 
			
		||||
      class="button scroll-notification"
 | 
			
		||||
      :class="hasNew ? 'is-warning' : 'is-primary'"
 | 
			
		||||
      @click="scrollToBottom"
 | 
			
		||||
      v-show="visible"
 | 
			
		||||
    >
 | 
			
		||||
      <span class="icon large"> <i class="fas fa-chevron-down"></i> </span>
 | 
			
		||||
    </button>
 | 
			
		||||
  </transition>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: ["messages"],
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      visible: false,
 | 
			
		||||
      hasNew: false
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    document.addEventListener("scroll", this.onScroll, { passive: true });
 | 
			
		||||
    setTimeout(() => this.scrollToBottom(), 500);
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {
 | 
			
		||||
    document.removeEventListener("scroll", this.onScroll);
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    scrollToBottom() {
 | 
			
		||||
      this.visible = false;
 | 
			
		||||
      window.scrollTo(0, document.documentElement.scrollHeight || document.body.scrollHeight);
 | 
			
		||||
    },
 | 
			
		||||
    onScroll() {
 | 
			
		||||
      const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
 | 
			
		||||
      const scrollBottom =
 | 
			
		||||
        (document.documentElement.scrollHeight || document.body.scrollHeight) - document.documentElement.clientHeight;
 | 
			
		||||
      const diff = Math.abs(scrollTop - scrollBottom);
 | 
			
		||||
      this.visible = diff > 50;
 | 
			
		||||
      if (!this.visible) {
 | 
			
		||||
        this.hasNew = false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    messages(newValue, oldValue) {
 | 
			
		||||
      if (this.visible) {
 | 
			
		||||
        this.hasNew = true;
 | 
			
		||||
      } else {
 | 
			
		||||
        this.scrollToBottom();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped>
 | 
			
		||||
.scroll-notification {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  right: 40px;
 | 
			
		||||
  bottom: 30px;
 | 
			
		||||
}
 | 
			
		||||
.fade-enter-active,
 | 
			
		||||
.fade-leave-active {
 | 
			
		||||
  transition: opacity 0.15s ease-in;
 | 
			
		||||
}
 | 
			
		||||
.fade-enter,
 | 
			
		||||
.fade-leave-to {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,16 +1,20 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
    <title>Dozzle!</title>
 | 
			
		||||
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="utf-8" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1" />
 | 
			
		||||
    <title>Dozzle</title>
 | 
			
		||||
    <link href="https://fonts.googleapis.com/css?family=Roboto|Roboto+Mono|Gafata" rel="stylesheet" />
 | 
			
		||||
    <link rel="manifest" href="manifest.webmanifest" />
 | 
			
		||||
    <link href="styles.scss" rel="stylesheet" />
 | 
			
		||||
    <script>
 | 
			
		||||
      window["BASE_PATH"] = "{{ .Base }}";
 | 
			
		||||
    </script>
 | 
			
		||||
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
<section class="section is-fullwidth">
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
  <body class="is-dark">
 | 
			
		||||
    <div id="app"></div>
 | 
			
		||||
</section>
 | 
			
		||||
<script src="/main.js"></script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
    <script src="main.js"></script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +1,34 @@
 | 
			
		||||
import Vue from "vue";
 | 
			
		||||
import VueRouter from "vue-router";
 | 
			
		||||
import vueHeadful from "vue-headful";
 | 
			
		||||
import App from "./App.vue";
 | 
			
		||||
import Index from "./pages/Index.vue";
 | 
			
		||||
import Container from "./pages/Container.vue";
 | 
			
		||||
import Index from "./pages/Index.vue";
 | 
			
		||||
 | 
			
		||||
Vue.use(VueRouter);
 | 
			
		||||
Vue.component("vue-headful", vueHeadful);
 | 
			
		||||
 | 
			
		||||
const routes = [
 | 
			
		||||
    {path: "/", component: Index},
 | 
			
		||||
    {
 | 
			
		||||
        path: "/container/:id",
 | 
			
		||||
        component: Container,
 | 
			
		||||
        name: "container",
 | 
			
		||||
        props: true
 | 
			
		||||
    }
 | 
			
		||||
  {
 | 
			
		||||
    path: "/",
 | 
			
		||||
    component: Index,
 | 
			
		||||
    name: "default"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: "/container/:id",
 | 
			
		||||
    component: Container,
 | 
			
		||||
    name: "container",
 | 
			
		||||
    props: true
 | 
			
		||||
  }
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const router = new VueRouter({
 | 
			
		||||
    mode: "history",
 | 
			
		||||
    routes
 | 
			
		||||
  mode: "history",
 | 
			
		||||
  base: BASE_PATH + "/",
 | 
			
		||||
  routes
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
new Vue({
 | 
			
		||||
    router,
 | 
			
		||||
    render: h => h(App)
 | 
			
		||||
  router,
 | 
			
		||||
  render: h => h(App)
 | 
			
		||||
}).$mount("#app");
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								assets/manifest.webmanifest
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								assets/manifest.webmanifest
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "Dozzle Log Viewer",
 | 
			
		||||
  "short_name": "Dozzle",
 | 
			
		||||
  "theme_color": "#111111",
 | 
			
		||||
  "background_color": "#111111",
 | 
			
		||||
  "display": "standalone",
 | 
			
		||||
  "scope": "/",
 | 
			
		||||
  "start_url": "/"
 | 
			
		||||
}
 | 
			
		||||
@@ -1,70 +1,94 @@
 | 
			
		||||
<template lang="html">
 | 
			
		||||
    <ul ref="events" class="events"></ul>
 | 
			
		||||
  <div class="is-fullheight">
 | 
			
		||||
    <ul ref="events" class="events">
 | 
			
		||||
      <li v-for="item in messages" class="event" :key="item.key">
 | 
			
		||||
        <span class="date">{{ item.dateRelative }}</span> <span class="text">{{ item.message }}</span>
 | 
			
		||||
      </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
    <scrollbar-notification :messages="messages"></scrollbar-notification>
 | 
			
		||||
    <vue-headful :title="title" />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
    import {  formatRelative } from 'date-fns'
 | 
			
		||||
    let ws;
 | 
			
		||||
import { formatRelative } from "date-fns";
 | 
			
		||||
import ScrollbarNotification from "../components/ScrollbarNotification";
 | 
			
		||||
 | 
			
		||||
    const parseMessage = (data) => {
 | 
			
		||||
        const date = new Date(data.substring(0, 30));
 | 
			
		||||
        const dateRelative = formatRelative(date, new Date());
 | 
			
		||||
        const message = data.substring(30);
 | 
			
		||||
        return {
 | 
			
		||||
            date,
 | 
			
		||||
            dateRelative,
 | 
			
		||||
            message
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    export default {
 | 
			
		||||
        props: ["id"],
 | 
			
		||||
        name: "Container",
 | 
			
		||||
        mounted() {
 | 
			
		||||
            ws = new WebSocket(`ws://${window.location.host}/api/logs?id=${this.id}`);
 | 
			
		||||
            ws.onopen = e => console.log("Connection opened.");
 | 
			
		||||
            ws.onclose = e => console.log("Connection closed.");
 | 
			
		||||
            ws.onerror = e => console.error("Connection error: " + e.data);
 | 
			
		||||
            ws.onmessage = e => {
 | 
			
		||||
                const data = parseMessage(e.data);
 | 
			
		||||
                const parent = this.$refs.events;
 | 
			
		||||
                const item = document.createElement("li");
 | 
			
		||||
                item.className = "event";
 | 
			
		||||
 | 
			
		||||
                const date = document.createElement("span");
 | 
			
		||||
                date.className = "date";
 | 
			
		||||
                date.innerHTML = data.dateRelative;
 | 
			
		||||
                item.appendChild(date);
 | 
			
		||||
 | 
			
		||||
                const message = document.createElement("span");
 | 
			
		||||
                message.className = "text";
 | 
			
		||||
                message.innerHTML = data.message;
 | 
			
		||||
                item.appendChild(message);
 | 
			
		||||
 | 
			
		||||
                parent.appendChild(item);
 | 
			
		||||
 | 
			
		||||
                this.$nextTick(() => item.scrollIntoView());
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
let es = null;
 | 
			
		||||
let nextId = 0;
 | 
			
		||||
const parseMessage = data => {
 | 
			
		||||
  const date = new Date(data.substring(0, 30));
 | 
			
		||||
  const dateRelative = formatRelative(date, new Date());
 | 
			
		||||
  const message = data.substring(30);
 | 
			
		||||
  const key = nextId++;
 | 
			
		||||
  return {
 | 
			
		||||
    key,
 | 
			
		||||
    date,
 | 
			
		||||
    dateRelative,
 | 
			
		||||
    message
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  props: ["id", "name"],
 | 
			
		||||
  name: "Container",
 | 
			
		||||
  components: {
 | 
			
		||||
    ScrollbarNotification
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      messages: [],
 | 
			
		||||
      title: ""
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  created() {
 | 
			
		||||
    this.loadLogs(this.id);
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {
 | 
			
		||||
    if (es) {
 | 
			
		||||
      es.close();
 | 
			
		||||
      es = null;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    id(newValue, oldValue) {
 | 
			
		||||
      if (oldValue !== newValue) {
 | 
			
		||||
        this.loadLogs(newValue);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    loadLogs(id) {
 | 
			
		||||
      if (es) {
 | 
			
		||||
        es.close();
 | 
			
		||||
        es = null;
 | 
			
		||||
        this.messages = [];
 | 
			
		||||
      }
 | 
			
		||||
      es = new EventSource(`${BASE_PATH}/api/logs/stream?id=${id}`);
 | 
			
		||||
      es.onmessage = e => this.messages.push(parseMessage(e.data));
 | 
			
		||||
      this.title = `${this.name} - Dozzle`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
<style>
 | 
			
		||||
    .events {
 | 
			
		||||
        color: #ddd;
 | 
			
		||||
        background-color: #111;
 | 
			
		||||
        padding: 10px;
 | 
			
		||||
    }
 | 
			
		||||
<style scoped>
 | 
			
		||||
.events {
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  font-family: "Roboto Mono", monaco, monospace;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    .event {
 | 
			
		||||
        font-family: monaco, monospace;
 | 
			
		||||
        font-size: 12px;
 | 
			
		||||
        line-height: 16px;
 | 
			
		||||
        padding: 0 15px 0 30px;
 | 
			
		||||
        word-wrap: break-word;
 | 
			
		||||
    }
 | 
			
		||||
.event {
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  line-height: 16px;
 | 
			
		||||
  word-wrap: break-word;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    .date {
 | 
			
		||||
        background-color: #262626;
 | 
			
		||||
        color: #258CCD;
 | 
			
		||||
    }
 | 
			
		||||
</style>
 | 
			
		||||
.date {
 | 
			
		||||
  background-color: #262626;
 | 
			
		||||
  color: #258ccd;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.is-fullheight {
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +1,22 @@
 | 
			
		||||
<template lang="html">
 | 
			
		||||
    <div class="container">
 | 
			
		||||
        <div class="content">
 | 
			
		||||
            <section class="section">
 | 
			
		||||
                <ul class="is-marginless is-paddless">
 | 
			
		||||
                    <li v-for="item in containers" class=" unstyled box">
 | 
			
		||||
                        <router-link :to="{name: 'container', params: {id: item.Id}}" class="columns">
 | 
			
		||||
                            <div class="column">
 | 
			
		||||
 | 
			
		||||
                                <h2 class="is-2">{{ item.Names[0] }}</h2>
 | 
			
		||||
                                <span class="subtitle is-6 code">{{ item.Command}}</span>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="column is-4">
 | 
			
		||||
                                <span class="code">{{ item.Image }}</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="column is-narrow">
 | 
			
		||||
                                <span class="subtitle is-7">{{ item.Status}}</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </router-link>
 | 
			
		||||
                    </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
            </section>
 | 
			
		||||
        </div>
 | 
			
		||||
  <div class="hero is-fullheight is-dark">
 | 
			
		||||
    <div class="hero-body">
 | 
			
		||||
      <div class="container has-text-centered">
 | 
			
		||||
        <h1 class="title">Please choose a container from the list to view the logs</h1>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
    export default {
 | 
			
		||||
        name: "Index",
 | 
			
		||||
        data() {
 | 
			
		||||
            return {
 | 
			
		||||
                containers: []
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        async created() {
 | 
			
		||||
            this.containers = await (await fetch(`/api/containers.json`)).json();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
export default {
 | 
			
		||||
  props: [],
 | 
			
		||||
  name: "Default"
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="css">
 | 
			
		||||
    .code {
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        background-color: #f5f5f5;
 | 
			
		||||
        color: #ff3860;
 | 
			
		||||
        font-size: .875em;
 | 
			
		||||
        font-weight: 400;
 | 
			
		||||
        padding: .25em .5em .25em;
 | 
			
		||||
        display: block;
 | 
			
		||||
        border-radius: 2px;
 | 
			
		||||
    }
 | 
			
		||||
</style>
 | 
			
		||||
<style scoped>
 | 
			
		||||
.hero.is-dark {
 | 
			
		||||
  color: #ddd;
 | 
			
		||||
  background-color: #111;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										19
									
								
								assets/styles.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								assets/styles.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
@charset "utf-8";
 | 
			
		||||
 | 
			
		||||
$menu-item-active-background-color: hsl(171, 100%, 41%);
 | 
			
		||||
$menu-item-color: hsl(0, 6%, 87%);
 | 
			
		||||
 | 
			
		||||
@import "../node_modules/bulma/bulma.sass";
 | 
			
		||||
 | 
			
		||||
.is-dark {
 | 
			
		||||
    color: #ddd;
 | 
			
		||||
    background-color: #111;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
    font-family: "Roboto", sans-serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1.title {
 | 
			
		||||
    font-family: "Gafata", sans-serif;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										123
									
								
								docker/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								docker/client.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
package docker
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/binary"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/api/types/events"
 | 
			
		||||
	"github.com/docker/docker/client"
 | 
			
		||||
	"io"
 | 
			
		||||
	"log"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type dockerClient struct {
 | 
			
		||||
	cli dockerProxy
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type dockerProxy interface {
 | 
			
		||||
	ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error)
 | 
			
		||||
	ContainerLogs(context.Context, string, types.ContainerLogsOptions) (io.ReadCloser, error)
 | 
			
		||||
	Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Client is a proxy around the docker client
 | 
			
		||||
type Client interface {
 | 
			
		||||
	ListContainers() ([]Container, error)
 | 
			
		||||
	ContainerLogs(ctx context.Context, id string) (<-chan string, <-chan error)
 | 
			
		||||
	Events(ctx context.Context) (<-chan events.Message, <-chan error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewClient creates a new instance of Client
 | 
			
		||||
func NewClient() Client {
 | 
			
		||||
	cli, err := client.NewClientWithOpts(client.FromEnv)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	return &dockerClient{cli}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (d *dockerClient) ListContainers() ([]Container, error) {
 | 
			
		||||
	list, err := d.cli.ContainerList(context.Background(), types.ContainerListOptions{})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var containers []Container
 | 
			
		||||
	for _, c := range list {
 | 
			
		||||
 | 
			
		||||
		container := Container{
 | 
			
		||||
			ID:      c.ID[:12],
 | 
			
		||||
			Names:   c.Names,
 | 
			
		||||
			Name:    strings.TrimPrefix(c.Names[0], "/"),
 | 
			
		||||
			Image:   c.Image,
 | 
			
		||||
			ImageID: c.ImageID,
 | 
			
		||||
			Command: c.Command,
 | 
			
		||||
			Created: c.Created,
 | 
			
		||||
			State:   c.State,
 | 
			
		||||
			Status:  c.Status,
 | 
			
		||||
		}
 | 
			
		||||
		containers = append(containers, container)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sort.Slice(containers, func(i, j int) bool {
 | 
			
		||||
		return containers[i].Name < containers[j].Name
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if containers == nil {
 | 
			
		||||
		containers = []Container{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return containers, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (d *dockerClient) ContainerLogs(ctx context.Context, id string) (<-chan string, <-chan error) {
 | 
			
		||||
	options := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "300", Timestamps: true}
 | 
			
		||||
 | 
			
		||||
	reader, err := d.cli.ContainerLogs(ctx, id, options)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		tmpErrors := make(chan error, 1)
 | 
			
		||||
		tmpErrors <- err
 | 
			
		||||
		return nil, tmpErrors
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		<-ctx.Done()
 | 
			
		||||
		reader.Close()
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	messages := make(chan string)
 | 
			
		||||
	errChannel := make(chan error)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		hdr := make([]byte, 8)
 | 
			
		||||
		var buffer bytes.Buffer
 | 
			
		||||
		for {
 | 
			
		||||
			_, err := reader.Read(hdr)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				errChannel <- err
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
			count := binary.BigEndian.Uint32(hdr[4:])
 | 
			
		||||
			_, err = io.CopyN(&buffer, reader, int64(count))
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				errChannel <- err
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
			messages <- buffer.String()
 | 
			
		||||
			buffer.Reset()
 | 
			
		||||
		}
 | 
			
		||||
		close(messages)
 | 
			
		||||
		close(errChannel)
 | 
			
		||||
		reader.Close()
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	return messages, errChannel
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (d *dockerClient) Events(ctx context.Context) (<-chan events.Message, <-chan error) {
 | 
			
		||||
	return d.cli.Events(ctx, types.EventsOptions{})
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										84
									
								
								docker/client_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								docker/client_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
package docker
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
	"github.com/stretchr/testify/mock"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type mockedProxy struct {
 | 
			
		||||
	mock.Mock
 | 
			
		||||
	dockerProxy
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *mockedProxy) ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error) {
 | 
			
		||||
	args := m.Called()
 | 
			
		||||
	containers, ok := args.Get(0).([]types.Container)
 | 
			
		||||
	if !ok && args.Get(0) != nil {
 | 
			
		||||
		panic("containers is not of type []types.Container")
 | 
			
		||||
	}
 | 
			
		||||
	return containers, args.Error(1)
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
func Test_dockerClient_ListContainers_null(t *testing.T) {
 | 
			
		||||
	proxy := mockedProxy{}
 | 
			
		||||
	proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, nil)
 | 
			
		||||
	client := &dockerClient{&proxy}
 | 
			
		||||
 | 
			
		||||
	list, err := client.ListContainers()
 | 
			
		||||
	assert.Empty(t, list, "list should be empty")
 | 
			
		||||
	require.NoError(t, err, "error should not return an error.")
 | 
			
		||||
 | 
			
		||||
	proxy.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_dockerClient_ListContainers_error(t *testing.T) {
 | 
			
		||||
	proxy := mockedProxy{}
 | 
			
		||||
	proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, errors.New("test"))
 | 
			
		||||
	client := &dockerClient{&proxy}
 | 
			
		||||
 | 
			
		||||
	list, err := client.ListContainers()
 | 
			
		||||
	assert.Nil(t, list, "list should be nil")
 | 
			
		||||
	require.Error(t, err, "test.")
 | 
			
		||||
 | 
			
		||||
	proxy.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_dockerClient_ListContainers_happy(t *testing.T) {
 | 
			
		||||
	containers := []types.Container{
 | 
			
		||||
		{
 | 
			
		||||
			ID:    "abcdefghijklmnopqrst",
 | 
			
		||||
			Names: []string{"/z_test_container"},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			ID:    "1234567890_abcxyzdef",
 | 
			
		||||
			Names: []string{"/a_test_container"},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	proxy := mockedProxy{}
 | 
			
		||||
	proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
 | 
			
		||||
	client := &dockerClient{&proxy}
 | 
			
		||||
 | 
			
		||||
	list, err := client.ListContainers()
 | 
			
		||||
	require.NoError(t, err, "error should not return an error.")
 | 
			
		||||
 | 
			
		||||
	assert.Equal(t, list, []Container{
 | 
			
		||||
		{
 | 
			
		||||
			ID:    "1234567890_a",
 | 
			
		||||
			Name:  "a_test_container",
 | 
			
		||||
			Names: []string{"/a_test_container"},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			ID:    "abcdefghijkl",
 | 
			
		||||
			Name:  "z_test_container",
 | 
			
		||||
			Names: []string{"/z_test_container"},
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	proxy.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								docker/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								docker/types.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
package docker
 | 
			
		||||
 | 
			
		||||
// Container represents an internal representation of docker containers
 | 
			
		||||
type Container struct {
 | 
			
		||||
	ID      string   `json:"id"`
 | 
			
		||||
	Names   []string `json:"names"`
 | 
			
		||||
	Name    string   `json:"name"`
 | 
			
		||||
	Image   string   `json:"image"`
 | 
			
		||||
	ImageID string   `json:"imageId"`
 | 
			
		||||
	Command string   `json:"command"`
 | 
			
		||||
	Created int64    `json:"created"`
 | 
			
		||||
	State   string   `json:"state"`
 | 
			
		||||
	Status  string   `json:"status"`
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										243
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										243
									
								
								main.go
									
									
									
									
									
								
							@@ -1,95 +1,202 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/binary"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"flag"
 | 
			
		||||
	"log"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/client"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"github.com/amir20/dozzle/docker"
 | 
			
		||||
	"github.com/gobuffalo/packr"
 | 
			
		||||
	"github.com/gorilla/websocket"
 | 
			
		||||
	"github.com/gorilla/mux"
 | 
			
		||||
	log "github.com/sirupsen/logrus"
 | 
			
		||||
	flag "github.com/spf13/pflag"
 | 
			
		||||
	"html/template"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	cli      *client.Client
 | 
			
		||||
	addr     = flag.String("addr", ":8080", "http service address")
 | 
			
		||||
	upgrader = websocket.Upgrader{}
 | 
			
		||||
	version  = "dev"
 | 
			
		||||
	commit   = "none"
 | 
			
		||||
	date     = "unknown"
 | 
			
		||||
	addr    = ""
 | 
			
		||||
	base    = ""
 | 
			
		||||
	level   = ""
 | 
			
		||||
	version = "dev"
 | 
			
		||||
	commit  = "none"
 | 
			
		||||
	date    = "unknown"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type handler struct {
 | 
			
		||||
	client docker.Client
 | 
			
		||||
	box    packr.Box
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	var err error
 | 
			
		||||
	cli, err = client.NewClientWithOpts(client.FromEnv)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	flag.StringVar(&addr, "addr", ":8080", "http service address")
 | 
			
		||||
	flag.StringVar(&base, "base", "/", "base address of the application to mount")
 | 
			
		||||
	flag.StringVar(&level, "level", "info", "logging level")
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
 | 
			
		||||
	l, _ := log.ParseLevel(level)
 | 
			
		||||
	log.SetLevel(l)
 | 
			
		||||
 | 
			
		||||
	log.SetFormatter(&log.TextFormatter{
 | 
			
		||||
		DisableTimestamp:       true,
 | 
			
		||||
		DisableLevelTruncation: true,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	dockerClient := docker.NewClient()
 | 
			
		||||
	_, err := dockerClient.ListContainers()
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("Could not connect to Docker Engine: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	box := packr.NewBox("./static")
 | 
			
		||||
	http.HandleFunc("/api/containers.json", listContainers)
 | 
			
		||||
	http.HandleFunc("/api/logs", logs)
 | 
			
		||||
	http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 | 
			
		||||
		fileServer := http.FileServer(box)
 | 
			
		||||
		if box.Has(req.URL.Path) {
 | 
			
		||||
			fileServer.ServeHTTP(w, req)
 | 
			
		||||
		} else {
 | 
			
		||||
			bytes, _ := box.Find("index.html")
 | 
			
		||||
			w.Write(bytes)
 | 
			
		||||
		}
 | 
			
		||||
	}))
 | 
			
		||||
	h := &handler{dockerClient, box}
 | 
			
		||||
 | 
			
		||||
	log.Fatal(http.ListenAndServe(*addr, nil))
 | 
			
		||||
	r := mux.NewRouter()
 | 
			
		||||
 | 
			
		||||
	if base != "/" {
 | 
			
		||||
		r.HandleFunc(base, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 | 
			
		||||
			http.Redirect(w, req, base+"/", http.StatusMovedPermanently)
 | 
			
		||||
		}))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	s := r.PathPrefix(base).Subrouter()
 | 
			
		||||
	s.HandleFunc("/api/containers.json", h.listContainers)
 | 
			
		||||
	s.HandleFunc("/api/logs/stream", h.streamLogs)
 | 
			
		||||
	s.HandleFunc("/api/events/stream", h.streamEvents)
 | 
			
		||||
	s.HandleFunc("/version", h.version)
 | 
			
		||||
	s.PathPrefix("/").Handler(http.StripPrefix(base, http.HandlerFunc(h.index)))
 | 
			
		||||
 | 
			
		||||
	log.Infof("Accepting connections on %s", addr)
 | 
			
		||||
	log.Fatal(http.ListenAndServe(addr, r))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func listContainers(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	json.NewEncoder(w).Encode(containers)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func logs(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	id := r.URL.Query().Get("id")
 | 
			
		||||
	c, err := upgrader.Upgrade(w, r, nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	defer c.Close()
 | 
			
		||||
 | 
			
		||||
	options := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "500", Timestamps: true}
 | 
			
		||||
	reader, err := cli.ContainerLogs(context.Background(), id, options)
 | 
			
		||||
	defer reader.Close()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	hdr := make([]byte, 8)
 | 
			
		||||
	content := make([]byte, 1024, 1024*1024)
 | 
			
		||||
	for {
 | 
			
		||||
		_, err := reader.Read(hdr)
 | 
			
		||||
func (h *handler) index(w http.ResponseWriter, req *http.Request) {
 | 
			
		||||
	fileServer := http.FileServer(h.box)
 | 
			
		||||
	if h.box.Has(req.URL.Path) && req.URL.Path != "" && req.URL.Path != "/" {
 | 
			
		||||
		fileServer.ServeHTTP(w, req)
 | 
			
		||||
	} else {
 | 
			
		||||
		text, _ := h.box.FindString("index.html")
 | 
			
		||||
		text = strings.Replace(text, "__BASE__", "{{ .Base }}", -1)
 | 
			
		||||
		tmpl, err := template.New("index.html").Parse(text)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			panic(err)
 | 
			
		||||
		}
 | 
			
		||||
		count := binary.BigEndian.Uint32(hdr[4:])
 | 
			
		||||
		n, err := reader.Read(content[:count])
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Println(err)
 | 
			
		||||
			break
 | 
			
		||||
 | 
			
		||||
		path := ""
 | 
			
		||||
		if base != "/" {
 | 
			
		||||
			path = base
 | 
			
		||||
		}
 | 
			
		||||
		err = c.WriteMessage(websocket.TextMessage, content[:n])
 | 
			
		||||
 | 
			
		||||
		data := struct{ Base string }{path}
 | 
			
		||||
		err = tmpl.Execute(w, data)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Println(err)
 | 
			
		||||
			break
 | 
			
		||||
			http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) listContainers(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	containers, err := h.client.ListContainers()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	err = json.NewEncoder(w).Encode(containers)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	id := r.URL.Query().Get("id")
 | 
			
		||||
	if id == "" {
 | 
			
		||||
		http.Error(w, "id is required", http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	f, ok := w.(http.Flusher)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	messages, err := h.client.ContainerLogs(r.Context(), id)
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", "text/event-stream")
 | 
			
		||||
	w.Header().Set("Cache-Control", "no-cache")
 | 
			
		||||
	w.Header().Set("Connection", "keep-alive")
 | 
			
		||||
	w.Header().Set("Transfer-Encoding", "chunked")
 | 
			
		||||
 | 
			
		||||
	log.Debugf("Starting to stream logs for %s", id)
 | 
			
		||||
Loop:
 | 
			
		||||
	for {
 | 
			
		||||
		select {
 | 
			
		||||
		case message, ok := <-messages:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				break Loop
 | 
			
		||||
			}
 | 
			
		||||
			_, e := fmt.Fprintf(w, "data: %s\n\n", message)
 | 
			
		||||
			if e != nil {
 | 
			
		||||
				log.Debugf("Error while writing to log stream: %v", e)
 | 
			
		||||
				break Loop
 | 
			
		||||
			}
 | 
			
		||||
			f.Flush()
 | 
			
		||||
		case e := <-err:
 | 
			
		||||
			log.Debugf("Error while reading from log stream: %v", e)
 | 
			
		||||
			break Loop
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	f, ok := w.(http.Flusher)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", "text/event-stream")
 | 
			
		||||
	w.Header().Set("Cache-Control", "no-cache")
 | 
			
		||||
	w.Header().Set("Connection", "keep-alive")
 | 
			
		||||
	w.Header().Set("Transfer-Encoding", "chunked")
 | 
			
		||||
 | 
			
		||||
	ctx := r.Context()
 | 
			
		||||
	messages, err := h.client.Events(ctx)
 | 
			
		||||
 | 
			
		||||
Loop:
 | 
			
		||||
	for {
 | 
			
		||||
		select {
 | 
			
		||||
		case message, ok := <-messages:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				break Loop
 | 
			
		||||
			}
 | 
			
		||||
			switch message.Action {
 | 
			
		||||
			case "connect", "disconnect", "create", "destroy", "start", "stop":
 | 
			
		||||
				log.Debugf("Triggering docker event: %v", message.Action)
 | 
			
		||||
				_, err := fmt.Fprintf(w, "event: containers-changed\ndata: %s\n\n", message.Action)
 | 
			
		||||
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Debugf("Error while writing to event stream: %v", err)
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
				f.Flush()
 | 
			
		||||
			default:
 | 
			
		||||
				log.Debugf("Ignoring docker event: %v", message.Action)
 | 
			
		||||
			}
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			break Loop
 | 
			
		||||
		case <-err:
 | 
			
		||||
			break Loop
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *handler) version(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	io.WriteString(w, version)
 | 
			
		||||
	io.WriteString(w, commit)
 | 
			
		||||
	io.WriteString(w, date)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										217
									
								
								main_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								main_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,217 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"github.com/docker/docker/api/types/events"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/http/httptest"
 | 
			
		||||
	"os"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/amir20/dozzle/docker"
 | 
			
		||||
	"github.com/beme/abide"
 | 
			
		||||
	"github.com/stretchr/testify/mock"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type MockedClient struct {
 | 
			
		||||
	mock.Mock
 | 
			
		||||
	docker.Client
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockedClient) ListContainers() ([]docker.Container, error) {
 | 
			
		||||
	args := m.Called()
 | 
			
		||||
	containers, ok := args.Get(0).([]docker.Container)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		panic("containers is not of type []docker.Container")
 | 
			
		||||
	}
 | 
			
		||||
	return containers, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockedClient) ContainerLogs(ctx context.Context, id string) (<-chan string, <-chan error) {
 | 
			
		||||
	args := m.Called(ctx, id)
 | 
			
		||||
	channel, ok := args.Get(0).(chan string)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		panic("channel is not of type chan string")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err, ok := args.Get(1).(chan error)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		panic("error is not of type chan error")
 | 
			
		||||
	}
 | 
			
		||||
	return channel, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockedClient) Events(ctx context.Context) (<-chan events.Message, <-chan error) {
 | 
			
		||||
	args := m.Called(ctx)
 | 
			
		||||
	channel, ok := args.Get(0).(chan events.Message)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		panic("channel is not of type chan events.Message")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err, ok := args.Get(1).(chan error)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		panic("error is not of type chan error")
 | 
			
		||||
	}
 | 
			
		||||
	return channel, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_handler_listContainers_happy(t *testing.T) {
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/containers.json", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
	containers := []docker.Container{
 | 
			
		||||
		{
 | 
			
		||||
			ID:      "1234567890",
 | 
			
		||||
			Status:  "status",
 | 
			
		||||
			State:   "state",
 | 
			
		||||
			Name:    "test",
 | 
			
		||||
			Created: 0,
 | 
			
		||||
			Command: "command",
 | 
			
		||||
			ImageID: "image_id",
 | 
			
		||||
			Image:   "image",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	mockedClient.On("ListContainers", mock.Anything).Return(containers, nil)
 | 
			
		||||
 | 
			
		||||
	h := handler{client: mockedClient}
 | 
			
		||||
	handler := http.HandlerFunc(h.listContainers)
 | 
			
		||||
	handler.ServeHTTP(rr, req)
 | 
			
		||||
	abide.AssertHTTPResponse(t, t.Name(), rr.Result())
 | 
			
		||||
	mockedClient.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_handler_streamLogs_happy(t *testing.T) {
 | 
			
		||||
	id := "123456"
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/logs/stream", nil)
 | 
			
		||||
	q := req.URL.Query()
 | 
			
		||||
	q.Add("id", "123456")
 | 
			
		||||
	req.URL.RawQuery = q.Encode()
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
 | 
			
		||||
	messages := make(chan string)
 | 
			
		||||
	errChannel := make(chan error)
 | 
			
		||||
	mockedClient.On("ContainerLogs", mock.Anything, id).Return(messages, errChannel)
 | 
			
		||||
	go func() {
 | 
			
		||||
		messages <- "INFO Testing logs..."
 | 
			
		||||
		close(messages)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	h := handler{client: mockedClient}
 | 
			
		||||
	handler := http.HandlerFunc(h.streamLogs)
 | 
			
		||||
	handler.ServeHTTP(rr, req)
 | 
			
		||||
	abide.AssertHTTPResponse(t, t.Name(), rr.Result())
 | 
			
		||||
	mockedClient.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_handler_streamLogs_error_reading(t *testing.T) {
 | 
			
		||||
	id := "123456"
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/logs/stream", nil)
 | 
			
		||||
	q := req.URL.Query()
 | 
			
		||||
	q.Add("id", "123456")
 | 
			
		||||
	req.URL.RawQuery = q.Encode()
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
	messages := make(chan string)
 | 
			
		||||
	errChannel := make(chan error)
 | 
			
		||||
	mockedClient.On("ContainerLogs", mock.Anything, id).Return(messages, errChannel)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		errChannel <- errors.New("test error")
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	h := handler{client: mockedClient}
 | 
			
		||||
	handler := http.HandlerFunc(h.streamLogs)
 | 
			
		||||
	handler.ServeHTTP(rr, req)
 | 
			
		||||
	abide.AssertHTTPResponse(t, t.Name(), rr.Result())
 | 
			
		||||
	mockedClient.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_handler_streamEvents_happy(t *testing.T) {
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/events/stream", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
	messages := make(chan events.Message)
 | 
			
		||||
	errChannel := make(chan error)
 | 
			
		||||
	mockedClient.On("Events", mock.Anything).Return(messages, errChannel)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		messages <- events.Message{
 | 
			
		||||
			Action: "start",
 | 
			
		||||
		}
 | 
			
		||||
		messages <- events.Message{
 | 
			
		||||
			Action: "something-random",
 | 
			
		||||
		}
 | 
			
		||||
		close(messages)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	h := handler{client: mockedClient}
 | 
			
		||||
	handler := http.HandlerFunc(h.streamEvents)
 | 
			
		||||
	handler.ServeHTTP(rr, req)
 | 
			
		||||
	abide.AssertHTTPResponse(t, t.Name(), rr.Result())
 | 
			
		||||
	mockedClient.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_handler_streamEvents_error(t *testing.T) {
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/events/stream", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
	messages := make(chan events.Message)
 | 
			
		||||
	errChannel := make(chan error)
 | 
			
		||||
	mockedClient.On("Events", mock.Anything).Return(messages, errChannel)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		errChannel <- errors.New("fake error")
 | 
			
		||||
		close(messages)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	h := handler{client: mockedClient}
 | 
			
		||||
	handler := http.HandlerFunc(h.streamEvents)
 | 
			
		||||
	handler.ServeHTTP(rr, req)
 | 
			
		||||
	abide.AssertHTTPResponse(t, t.Name(), rr.Result())
 | 
			
		||||
	mockedClient.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_handler_streamEvents_error_request(t *testing.T) {
 | 
			
		||||
	req, err := http.NewRequest("GET", "/api/events/stream", nil)
 | 
			
		||||
	require.NoError(t, err, "NewRequest should not return an error.")
 | 
			
		||||
 | 
			
		||||
	rr := httptest.NewRecorder()
 | 
			
		||||
 | 
			
		||||
	mockedClient := new(MockedClient)
 | 
			
		||||
 | 
			
		||||
	messages := make(chan events.Message)
 | 
			
		||||
	errChannel := make(chan error)
 | 
			
		||||
	mockedClient.On("Events", mock.Anything).Return(messages, errChannel)
 | 
			
		||||
 | 
			
		||||
	ctx, cancel := context.WithCancel(context.Background())
 | 
			
		||||
	req = req.WithContext(ctx)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		cancel()
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	h := handler{client: mockedClient}
 | 
			
		||||
	handler := http.HandlerFunc(h.streamEvents)
 | 
			
		||||
	handler.ServeHTTP(rr, req)
 | 
			
		||||
	abide.AssertHTTPResponse(t, t.Name(), rr.Result())
 | 
			
		||||
	mockedClient.AssertExpectations(t)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestMain(m *testing.M) {
 | 
			
		||||
	exit := m.Run()
 | 
			
		||||
	abide.Cleanup()
 | 
			
		||||
	os.Exit(exit)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1876
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1876
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										96
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										96
									
								
								package.json
									
									
									
									
									
								
							@@ -1,41 +1,61 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "dozzle",
 | 
			
		||||
  "version": "1.0.4",
 | 
			
		||||
  "description": "",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "concurrently 'go run main.go' 'npm run watch-assets'",
 | 
			
		||||
    "watch-assets": "parcel watch assets/index.html -d static",
 | 
			
		||||
    "build": "parcel build assets/index.html -d static",
 | 
			
		||||
    "clean": "rm -rf static"
 | 
			
		||||
  },
 | 
			
		||||
  "repository": {
 | 
			
		||||
    "type": "git",
 | 
			
		||||
    "url": "git+https://github.com/amir20/dozzle.git"
 | 
			
		||||
  },
 | 
			
		||||
  "author": "",
 | 
			
		||||
  "license": "ISC",
 | 
			
		||||
  "bugs": {
 | 
			
		||||
    "url": "https://github.com/amir20/dozzle/issues"
 | 
			
		||||
  },
 | 
			
		||||
  "homepage": "https://github.com/amir20/dozzle#readme",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "vue": "^2.5.17",
 | 
			
		||||
    "vue-router": "^3.0.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@vue/component-compiler-utils": "^2.3.0",
 | 
			
		||||
    "babel-core": "^6.26.3",
 | 
			
		||||
    "babel-plugin-transform-runtime": "^6.23.0",
 | 
			
		||||
    "babel-preset-env": "^1.7.0",
 | 
			
		||||
    "babel-runtime": "^6.26.0",
 | 
			
		||||
    "concurrently": "^4.0.1",
 | 
			
		||||
    "date-fns": "^2.0.0-alpha.25",
 | 
			
		||||
    "parcel-bundler": "^1.10.3",
 | 
			
		||||
    "vue-hot-reload-api": "^2.3.1",
 | 
			
		||||
    "vue-template-compiler": "^2.5.17"
 | 
			
		||||
  },
 | 
			
		||||
  "browserslist": [
 | 
			
		||||
    ">5%"
 | 
			
		||||
 "name": "dozzle",
 | 
			
		||||
 "version": "1.4.2",
 | 
			
		||||
 "description": "",
 | 
			
		||||
 "main": "index.js",
 | 
			
		||||
 "scripts": {
 | 
			
		||||
  "prestart": "npm run clean",
 | 
			
		||||
  "start": "DOCKER_API_VERSION=1.38 concurrently 'npm run watch-server' 'npm run watch-assets'",
 | 
			
		||||
  "watch-assets": "parcel watch --public-url '__BASE__' assets/index.html -d static",
 | 
			
		||||
  "watch-server": "reflex -g '**/*.go' -R '^node_modules/' -R '^static/' -R '^.cache/' -G '*_test.go' -s -- go run main.go --level debug",
 | 
			
		||||
  "prebuild": "npm run clean",
 | 
			
		||||
  "build": "parcel build --no-source-maps --public-url '__BASE__' assets/index.html -d static",
 | 
			
		||||
  "clean": "rm -rf static/ a_main-packr.go",
 | 
			
		||||
  "release": "goreleaser --rm-dist"
 | 
			
		||||
 },
 | 
			
		||||
 "repository": {
 | 
			
		||||
  "type": "git",
 | 
			
		||||
  "url": "git+https://github.com/amir20/dozzle.git"
 | 
			
		||||
 },
 | 
			
		||||
 "author": "",
 | 
			
		||||
 "license": "ISC",
 | 
			
		||||
 "bugs": {
 | 
			
		||||
  "url": "https://github.com/amir20/dozzle/issues"
 | 
			
		||||
 },
 | 
			
		||||
 "homepage": "https://github.com/amir20/dozzle#readme",
 | 
			
		||||
 "dependencies": {
 | 
			
		||||
  "bulma": "^0.7.2",
 | 
			
		||||
  "date-fns": "^2.0.0-alpha.25",
 | 
			
		||||
  "vue": "^2.5.17",
 | 
			
		||||
  "vue-headful": "^2.0.1",
 | 
			
		||||
  "vue-router": "^3.0.2"
 | 
			
		||||
 },
 | 
			
		||||
 "devDependencies": {
 | 
			
		||||
  "@babel/core": "^7.1.6",
 | 
			
		||||
  "@babel/plugin-transform-runtime": "^7.1.0",
 | 
			
		||||
  "@vue/component-compiler-utils": "^2.3.0",
 | 
			
		||||
  "concurrently": "^4.1.0",
 | 
			
		||||
  "husky": "^1.2.0",
 | 
			
		||||
  "lint-staged": "^8.1.0",
 | 
			
		||||
  "parcel-bundler": "^1.10.3",
 | 
			
		||||
  "prettier": "^1.15.2",
 | 
			
		||||
  "sass": "^1.15.1",
 | 
			
		||||
  "vue-hot-reload-api": "^2.3.1",
 | 
			
		||||
  "vue-template-compiler": "^2.5.17"
 | 
			
		||||
 },
 | 
			
		||||
 "husky": {
 | 
			
		||||
  "hooks": {
 | 
			
		||||
   "pre-commit": "lint-staged"
 | 
			
		||||
  }
 | 
			
		||||
 },
 | 
			
		||||
 "lint-staged": {
 | 
			
		||||
  "*.{js,vue,css}": [
 | 
			
		||||
   "prettier --write",
 | 
			
		||||
   "git add"
 | 
			
		||||
  ]
 | 
			
		||||
 },
 | 
			
		||||
 "browserslist": [
 | 
			
		||||
  ">5%",
 | 
			
		||||
  "not ie <= 8"
 | 
			
		||||
 ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user