Compare commits

...

58 Commits

Author SHA1 Message Date
Amir Raminfar
5dc4d4c4d1 Release 3.10.0 2021-11-29 10:22:44 -08:00
Amir Raminfar
87d80d8284 Updates modules 2021-11-29 10:19:10 -08:00
Amir Raminfar
e216f54d6e fixes biuld 2021-11-27 15:48:59 -08:00
Amir Raminfar
cfa9c702d0 Revert "Updates node modules"
This reverts commit 05ae16df8b.
2021-11-27 15:46:31 -08:00
Amir Raminfar
15fa6ae8b0 Minor clean up 2021-11-27 14:26:30 -08:00
Amir Raminfar
05ae16df8b Updates node modules 2021-11-27 14:14:26 -08:00
Amir Raminfar
34232ef956 Fixes file in gzip when downloading 2021-11-27 13:40:49 -08:00
Amir Raminfar
da35a13d04 Updates snapshots and fixes bugs 2021-11-22 14:22:04 -08:00
Amir Raminfar
cdca0efd05 Adds more tests for routes 2021-11-21 19:56:10 -08:00
Amir Raminfar
320bbfe8b2 Adds more tests 2021-11-21 19:25:44 -08:00
Amir Raminfar
bf42fd4fea Adds more tests 2021-11-21 18:33:58 -08:00
Amir Raminfar
958a1463e6 Updats node packages 2021-11-21 09:33:19 -08:00
Amir Raminfar
4138630fc4 Updates packages 2021-11-21 09:08:41 -08:00
Amir Raminfar
91545f932c Fixes icon 2021-11-20 20:00:35 -08:00
Amir Raminfar
36cc93dacc Fixes types for stat 2021-11-20 19:58:49 -08:00
Amir Raminfar
43e777687d Adds container stats 2021-11-20 19:51:44 -08:00
Amir Raminfar
037a76f5c7 Fixes toolbar 2021-11-20 19:32:07 -08:00
Coteh
41c54a02eb Move actions toolbar to side menu 2021-11-20 15:23:09 -08:00
Amir Raminfar
7901c21843 Pinia (#1606)
* Migrates to pinia

* Does more pinia migration

* Fixes types

* Move pinia

* Adds search cleans up pinia

* Removes unused files

* Removes vuex

* More clean up

* Makes js tests pass

* Fixes bugs and removing active container
2021-11-20 15:22:13 -08:00
Amir Raminfar
257110bc64 Moves default dev port to 3100. See #1601 2021-11-19 09:55:44 -08:00
Amir Raminfar
e2072d35c8 Settings to useStorage (#1602)
* Uses storage for settings

* Cleans settings

* Fixes tests for storage

* Removes store.js

* Uses ts

* Fixes tests

* Removes autoprefixer
2021-11-18 14:06:54 -08:00
kodiakhq[bot]
4a303d3ffa Merge pull request #1600 from amir20/dependabot/go_modules/github.com/docker/docker-20.10.11incompatible
Bump github.com/docker/docker from 20.10.10+incompatible to 20.10.11+incompatible
2021-11-18 09:40:47 +00:00
dependabot[bot]
57d8a90000 Bump github.com/docker/docker
Bumps [github.com/docker/docker](https://github.com/docker/docker) from 20.10.10+incompatible to 20.10.11+incompatible.
- [Release notes](https://github.com/docker/docker/releases)
- [Changelog](https://github.com/moby/moby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/docker/docker/compare/v20.10.10...v20.10.11)

---
updated-dependencies:
- dependency-name: github.com/docker/docker
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-18 09:33:45 +00:00
Amir Raminfar
0d54a265d9 Updats modules 2021-11-16 14:33:19 -08:00
Amir Raminfar
412a10256d Vue3 (#1594)
* WIP vue3

* WIP vue3

* WIP vue3

* Migrates to vitejs

* Fixes js tests and removes not needed modules

* Fixes unmount

* Updates to use css instead for space

* Fixes tests and rebases one more time

* Uses orgua

* Fixes migrations bugs with oruga and fixes scroll

* Fixes v-deep

* Fixes icons to prod

* Fixes icons to prod

* Adds favicon back

* Transitions some to composition api

* Updates another component to comp api

* Cleans defineProps

* Updates log messages

* Moves more to compose api

* Cleans up styles and rewrites event source

* Tries to fix DOMPurify

* Removes postcss

* WIP typescript

* Improves importing

* Converts all to ts

* Converts main to ts

* Makes changes for tsconfig

* Moves more to ts

* Adds typing to store

* More typing

* Updates to ts

* Updates the rest to ts

* Fixes computes

* Fixes unmount

* Adds cypress with custom base fixed

* Fixes jest tests

* Fixes golang tests

* Adds gitignore for cypress

* Removes int in favor of e2e with cypress

* Tries to fix int tests again

* Adds title

* Updates e2e tests

* Uses vue for isMobile

* Removes app spec

* Cleans up docker

* Adds drop down for settings

* Fixes bug with restart

* Fixes scroll up bug

* Adds tests for light mode
2021-11-16 10:55:44 -08:00
Amir Raminfar
215ea12e80 Fixes release 2021-11-09 14:53:57 -08:00
Amir Raminfar
b72e208f27 Release 3.9.0 2021-11-09 14:15:43 -08:00
Amir Raminfar
0714809fd9 Renames job 2021-11-09 14:07:50 -08:00
Amir Raminfar
17d43453cc Renames dev job 2021-11-09 14:05:07 -08:00
Amir Raminfar
ce120ac194 Adds pr pushes 2021-11-08 11:38:06 -08:00
Amir Raminfar
f19bbb8d38 Fixes int tests 2021-11-07 14:42:55 -08:00
Amir Raminfar
4f7cbb7cdf Fixes test cases 2021-11-07 14:39:27 -08:00
Coteh
3672a4729d Use .icon class instead of vertically aligning through CSS 2021-11-07 15:46:32 -05:00
Coteh
b0d1cd257c Add autoInstall setting for unplugin icons 2021-11-07 15:35:54 -05:00
Coteh
be23ef93eb Update pnpm-lock.yaml 2021-11-07 15:02:27 -05:00
Coteh
07d3176178 Switch to unplugin-icons for icons
The following icons are used:
Material Design Light
- Chevron Left (Hide Sidebar)
- Chevron Right (Show Sidebar)
- Chevron Double Down (Page Down)
- Magnify (Search)
- Cog (Settings)

Octicon
- Trash 24 (Clear Logs)
- Download 24 (Download Logs)
- Container 24 (Container Listing)

CoreUI Free
- Columns (Pin as Column)
2021-11-07 15:02:27 -05:00
Amir Raminfar
b01020dc0e Removes yarn 2021-11-07 07:20:56 -08:00
Amir Raminfar
4e5fedb18f Fixes pnpm 2021-11-07 07:03:46 -08:00
Amir Raminfar
dcd1fcfcde Updates gh actions 2021-11-07 07:02:09 -08:00
Amir Raminfar
fb777d4dbf Fixes yarn 2021-11-05 19:09:39 -07:00
Amir Raminfar
7b1f4f7f34 Remoges unused import 2021-11-05 19:09:39 -07:00
kodiakhq[bot]
d88eb339b4 Merge pull request #1591 from amir20/dependabot/docker/golang-1.17.3-alpine
Bump golang from 1.17.2-alpine to 1.17.3-alpine
2021-11-05 09:16:20 +00:00
dependabot[bot]
a84ef7be66 Bump golang from 1.17.2-alpine to 1.17.3-alpine
Bumps golang from 1.17.2-alpine to 1.17.3-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-05 09:11:37 +00:00
Amir Raminfar
fc798985fd Fixes follow ups #1584 2021-11-04 19:03:33 -07:00
Amir Raminfar
df176c39f5 Updates go modules 2021-11-04 18:54:42 -07:00
James Cote
49b39fb3af Add clear logs functionality (#1584)
* Add clear logs functionality

Other Changes:
- Add actions toolbar for actions pertaining to container logs
- Move Download button to actions toolbar, remove text and margin to match clear logs button
- Move search box below actions toolbar
- Turn off pointer events for scroll progress component so that Download button can be clicked
- Adjust spacing of container bar to accomodate the new actions toolbar

* Change actions toolbar into a floating menu

Other Changes:
- Move actions toolbar into its own component
- Move clear hotkey from LogEventSource to LogActionsToolbar, where the button is
- Move search back to where it was before
- Add tooltip component from buefy to annotate action buttons
- Add CSS variable for action toolbar background color, changes based on theme
2021-11-04 18:48:51 -07:00
Amir Raminfar
d9e8cca867 Fixes #1589 2021-11-04 15:52:53 -07:00
kodiakhq[bot]
bdead5c55d Merge pull request #1590 from amir20/dependabot/npm_and_yarn/integration/puppeteer-11.0.0
Bump puppeteer from 10.4.0 to 11.0.0 in /integration
2021-11-04 09:08:44 +00:00
dependabot[bot]
05b0525a4b Bump puppeteer from 10.4.0 to 11.0.0 in /integration
Bumps [puppeteer](https://github.com/puppeteer/puppeteer) from 10.4.0 to 11.0.0.
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/v10.4.0...v11.0.0)

---
updated-dependencies:
- dependency-name: puppeteer
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-04 09:04:10 +00:00
kodiakhq[bot]
fa502cdda3 Merge pull request #1588 from amir20/dependabot/github_actions/actions/checkout-2.4.0
Bump actions/checkout from 2.3.5 to 2.4.0
2021-11-03 09:12:58 +00:00
dependabot[bot]
dee345b618 Bump actions/checkout from 2.3.5 to 2.4.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 2.3.5 to 2.4.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2.3.5...v2.4.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-03 09:08:25 +00:00
Amir Raminfar
d55f78829e Moves stats to narrow and possibly improves #1582 2021-11-01 14:42:00 -07:00
Amir Raminfar
8f4264e26a Update README.md 2021-10-29 15:07:03 -07:00
Amir Raminfar
c79ce7237e Release 3.8.5 2021-10-29 14:47:56 -07:00
Amir Raminfar
eeec34b018 Removes @sha... 2021-10-29 14:26:50 -07:00
Amir Raminfar
69acb24aee Fixes prod deps 2021-10-29 14:26:27 -07:00
kodiakhq[bot]
61afc74215 Merge pull request #1580 from amir20/dependabot/docker/node-17-alpine
Bump node from 16-alpine to 17-alpine
2021-10-29 18:13:43 +00:00
dependabot[bot]
396f4be965 Bump node from 16-alpine to 17-alpine
Bumps node from 16-alpine to 17-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-10-29 18:09:33 +00:00
107 changed files with 5794 additions and 9300 deletions

View File

@@ -1,8 +1,4 @@
{
"presets": [["env", { "modules": false }]],
"env": {
"test": {
"presets": [["env", { "targets": { "node": "current" } }]]
}
}
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime"]
}

View File

@@ -1,8 +1,8 @@
node_modules
.cache
.idea
.github
dist
.git
static
integration
demo.gif
e2e

View File

@@ -31,7 +31,7 @@ updates:
schedule:
interval: daily
- package-ecosystem: npm
directory: "/integration"
directory: "/e2e"
labels:
- "npm"
- "dependencies"

View File

@@ -9,13 +9,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
- name: Install Node
uses: actions/setup-node@v2.4.1
- name: Install pnpm
uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.1
- name: Install dependencies
run: yarn
run: pnpm install
- name: Run Tests
run: yarn test
run: pnpm run test
go-test:
name: Go Tests
runs-on: ubuntu-latest
@@ -25,7 +29,7 @@ jobs:
with:
go-version: 1.17.x
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
- name: Run Go Tests with Coverage
run: make test SKIP_ASSET=1
int-test:
@@ -33,11 +37,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
- name: Build images
run: docker-compose -f integration/docker-compose.test.yml build
run: docker-compose -f e2e/docker-compose.yml build
- name: Run tests
run: docker-compose -f integration/docker-compose.test.yml run integration
run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
buildx:
needs: [go-test, npm-test, int-test]
name: Release
@@ -73,14 +77,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
with:
fetch-depth: 0
- name: Install Node
uses: actions/setup-node@v2.4.1
- name: Install pnpm
uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.1
- name: Install dependencies
run: yarn
run: pnpm install
- name: Release to Github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: yarn release --github.release --no-increment --no-git --ci
run: pnpm run release --github.release --no-increment --no-git --ci

View File

@@ -2,10 +2,13 @@ on:
push:
branches:
- master
name: Push master container
pull_request:
branches:
- master
name: Push container
jobs:
buildx:
name: Push master
name: Push branches and PRs
runs-on: ubuntu-latest
steps:
- name: Docker meta

View File

@@ -1,4 +1,10 @@
on: push
on:
push:
branches:
- "**"
pull_request:
branches:
- master
name: Test
jobs:
npm-test:
@@ -6,13 +12,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
- name: Install Node
uses: actions/setup-node@v2.4.1
- name: Install pnpm
uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.1
- name: Install dependencies
run: yarn
run: pnpm install
- name: Run Tests
run: yarn test
run: pnpm run test
go-test:
name: Go Tests
runs-on: ubuntu-latest
@@ -22,7 +32,7 @@ jobs:
with:
go-version: 1.17.x
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
- name: Run Go Tests with Coverage
run: make test SKIP_ASSET=1
int-test:
@@ -30,8 +40,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2.3.5
uses: actions/checkout@v2.4.0
- name: Build images
run: docker-compose -f integration/docker-compose.test.yml build
run: docker-compose -f e2e/docker-compose.yml build
- name: Run tests
run: docker-compose -f integration/docker-compose.test.yml run integration
run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress

View File

@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname $0)/_/husky.sh"
yarn lint-staged
pnpm lint-staged

View File

@@ -1,5 +1,5 @@
# Build assets
FROM node:16-alpine as node
FROM node:17-alpine as node
RUN apk add --no-cache git openssh make g++ util-linux curl python3 && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
@@ -7,21 +7,21 @@ WORKDIR /build
# Install dependencies from lock file
COPY pnpm-lock.yaml ./
RUN pnpm fetch
RUN pnpm fetch --prod
# Copy files
COPY package.json .* webpack*.js ./
COPY package.json .* vite.config.ts index.html ./
# Copy assets to build
COPY assets ./assets
# Install dependencies
RUN pnpm install -r --offline
RUN pnpm install -r --offline --prod
# Do the build
RUN pnpm build
FROM golang:1.17.2-alpine AS builder
FROM golang:1.17.3-alpine AS builder
RUN apk add --no-cache git ca-certificates && mkdir /dozzle
@@ -32,10 +32,13 @@ COPY go.* ./
RUN go mod download
# Copy assets built with node
COPY --from=node /build/static ./static
COPY --from=node /build/dist ./dist
# Copy all other files
COPY . .
COPY analytics ./analytics
COPY docker ./docker
COPY web ./web
COPY main.go ./
# Args
ARG TAG=dev

View File

@@ -1,24 +1,24 @@
.PHONY: clean
clean:
@rm -rf static
@rm -rf dist
@go clean -i
.PHONY: static
static:
.PHONY: dist
dist:
@pnpm build
.PHONY: fake_static
fake_static:
.PHONY: fake_assets
fake_assets:
@echo 'Skipping asset build'
@mkdir -p static
@echo "assets build was skipped" > static/index.html
@mkdir -p dist
@echo "assets build was skipped" > dist/index.html
.PHONY: test
test: fake_static
test: fake_assets
go test -cover ./...
.PHONY: build
build: static
build: dist
CGO_ENABLED=0 go build -ldflags "-s -w"
.PHONY: docker
@@ -31,4 +31,4 @@ dev:
.PHONY: int
int:
docker-compose -f integration/docker-compose.test.yml up --build --force-recreate --exit-code-from integration
docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress

View File

@@ -6,8 +6,7 @@ Dozzle is a small lightweight application with a web based interface to monitor
[![Go Report Card](https://goreportcard.com/badge/github.com/amir20/dozzle)](https://goreportcard.com/report/github.com/amir20/dozzle)
[![Docker Pulls](https://img.shields.io/docker/pulls/amir20/dozzle.svg)](https://hub.docker.com/r/amir20/dozzle/)
[![Docker Size](https://images.microbadger.com/badges/image/amir20/dozzle.svg)](https://hub.docker.com/r/amir20/dozzle/)
[![Docker Version](https://images.microbadger.com/badges/version/amir20/dozzle.svg)](https://hub.docker.com/r/amir20/dozzle/)
[![Docker Version](https://img.shields.io/docker/v/amir20/dozzle?sort=semver)](https://hub.docker.com/r/amir20/dozzle/)
![Test](https://github.com/amir20/dozzle/workflows/Test/badge.svg)
## Features

View File

@@ -1,54 +0,0 @@
import EventSource from "eventsourcemock";
import { shallowMount, RouterLinkStub, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import App from "./App";
jest.mock("./store/config.js", () => ({ base: "" }));
const localVue = createLocalVue();
localVue.use(Vuex);
describe("<App />", () => {
const stubs = { RouterLink: RouterLinkStub, "router-view": true, icon: true };
let store;
beforeEach(() => {
global.EventSource = EventSource;
const state = {
settings: { menuWidth: 15 },
containers: [{ id: "abc", name: "Test 1" }],
};
const getters = {
visibleContainers(store) {
return store.containers;
},
activeContainers() {
return [];
},
};
store = new Vuex.Store({
state,
getters,
});
});
test("has right title", async () => {
const wrapper = shallowMount(App, { stubs, store, localVue });
wrapper.vm.$store.state.containers = [
{ id: "abc", name: "Test 1" },
{ id: "xyz", name: "Test 2" },
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.title).toContain("2 containers");
});
test("renders correctly", async () => {
const wrapper = shallowMount(App, { stubs, store, localVue });
await wrapper.vm.$nextTick();
expect(wrapper.element).toMatchSnapshot();
});
});

View File

@@ -3,7 +3,7 @@
<mobile-menu v-if="isMobile && !authorizationNeeded"></mobile-menu>
<splitpanes @resized="onResized($event)">
<pane min-size="10" :size="settings.menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
<pane min-size="10" :size="menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
<side-menu @search="showFuzzySearch"></side-menu>
</pane>
<pane min-size="10">
@@ -18,7 +18,7 @@
show-title
scrollable
closable
@close="removeActiveContainer(other)"
@close="containerStore.removeActiveContainer(other)"
></log-container>
</pane>
</template>
@@ -27,122 +27,97 @@
</splitpanes>
<button
@click="collapseNav = !collapseNav"
class="button is-small is-rounded is-settings-control"
class="button is-rounded"
:class="{ collapsed: collapseNav }"
id="hide-nav"
v-if="!isMobile && !authorizationNeeded"
>
<span class="icon">
<icon :name="collapseNav ? 'chevron-right' : 'chevron-left'"></icon>
<span class="icon ml-2" v-if="collapseNav">
<mdi-light-chevron-right />
</span>
<span class="icon" v-else>
<mdi-light-chevron-left />
</span>
</button>
</main>
</template>
<script>
import { mapActions, mapGetters, mapState } from "vuex";
<script lang="ts" setup>
import { Splitpanes, Pane } from "splitpanes";
import { ref, onMounted, watchEffect } from "vue";
import { storeToRefs } from "pinia";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import hotkeys from "hotkeys-js";
import LogContainer from "./components/LogContainer";
import SideMenu from "./components/SideMenu";
import MobileMenu from "./components/MobileMenu";
import { setTitle } from "@/composables/title";
import { isMobile } from "@/composables/media";
import { smallerScrollbars, lightTheme, menuWidth } from "@/composables/settings";
import { useContainerStore } from "@/stores/container";
import config from "@/stores/config";
import PastTime from "./components/PastTime";
import Icon from "./components/Icon";
import FuzzySearchModal from "./components/FuzzySearchModal";
import FuzzySearchModal from "@/components/FuzzySearchModal.vue";
import LogContainer from "@/components/LogContainer.vue";
import SideMenu from "@/components/SideMenu.vue";
import MobileMenu from "@/components/MobileMenu.vue";
export default {
name: "App",
components: {
Icon,
SideMenu,
LogContainer,
MobileMenu,
Splitpanes,
PastTime,
Pane,
},
data() {
return {
title: "",
collapseNav: false,
};
},
metaInfo() {
return {
title: this.title,
titleTemplate: "%s - Dozzle",
};
},
mounted() {
if (this.hasSmallerScrollbars) {
document.documentElement.classList.add("has-custom-scrollbars");
}
if (this.hasLightTheme) {
document.documentElement.setAttribute("data-theme", "light");
}
this.menuWidth = this.settings.menuWidth;
hotkeys("command+k, ctrl+k", (event, handler) => {
event.preventDefault();
this.showFuzzySearch();
});
},
watch: {
hasSmallerScrollbars(newValue, oldValue) {
if (newValue) {
document.documentElement.classList.add("has-custom-scrollbars");
} else {
document.documentElement.classList.remove("has-custom-scrollbars");
}
},
hasLightTheme(newValue, oldValue) {
if (newValue) {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
},
visibleContainers() {
this.title = `${this.visibleContainers.length} containers`;
},
},
computed: {
...mapState(["isMobile", "settings", "containers", "authorizationNeeded"]),
...mapGetters(["visibleContainers", "activeContainers"]),
hasSmallerScrollbars() {
return this.settings.smallerScrollbars;
},
hasLightTheme() {
return this.settings.lightTheme;
},
},
methods: {
...mapActions({
removeActiveContainer: "REMOVE_ACTIVE_CONTAINER",
updateSetting: "UPDATE_SETTING",
}),
onResized(e) {
if (e.length == 2) {
const menuWidth = e[0].size;
this.updateSetting({ menuWidth });
}
},
showFuzzySearch() {
this.$buefy.modal.open({
parent: this,
component: FuzzySearchModal,
animation: "false",
width: 600,
});
},
},
};
const collapseNav = ref(false);
const { oruga } = useProgrammatic();
const { authorizationNeeded } = config;
const containerStore = useContainerStore();
const { activeContainers, visibleContainers } = storeToRefs(containerStore);
onMounted(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars");
}
if (lightTheme.value) {
document.documentElement.setAttribute("data-theme", "light");
}
hotkeys("command+k, ctrl+k", (event, handler) => {
event.preventDefault();
showFuzzySearch();
});
});
watchEffect(() => {
setTitle(`${visibleContainers.value.length} containers`);
});
watchEffect(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars");
} else {
document.documentElement.classList.remove("has-custom-scrollbars");
}
if (lightTheme.value) {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
});
function showFuzzySearch() {
oruga.modal.open({
// parent: this,
component: FuzzySearchModal,
animation: "false",
width: 600,
active: true,
});
}
function onResized(e) {
if (e.length == 2) {
menuWidth.value = e[0].size;
}
}
</script>
<style scoped lang="scss">
::v-deep .splitpanes--vertical > .splitpanes__splitter {
:deep(.splitpanes--vertical > .splitpanes__splitter) {
min-width: 3px;
background: var(--border-color);
&:hover {

View File

@@ -1,52 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<App /> renders correctly 1`] = `
<main>
<!---->
<splitpanes-stub
dblclicksplitter="true"
pushotherpanes="true"
>
<pane-stub
maxsize="100"
minsize="10"
size="15"
>
<side-menu-stub />
</pane-stub>
<pane-stub
maxsize="100"
minsize="10"
>
<splitpanes-stub
dblclicksplitter="true"
pushotherpanes="true"
>
<pane-stub
class="has-min-height router-view"
maxsize="100"
minsize="0"
>
<router-view-stub />
</pane-stub>
</splitpanes-stub>
</pane-stub>
</splitpanes-stub>
<button
class="button is-small is-rounded is-settings-control"
id="hide-nav"
>
<span
class="icon"
>
<icon-stub
name="chevron-left"
/>
</span>
</button>
</main>
`;

37
assets/components.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/vue-next/pull/3399
declare module 'vue' {
export interface GlobalComponents {
CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default']
CilColumns: typeof import('~icons/cil/columns')['default']
ContainerStat: typeof import('./components/ContainerStat.vue')['default']
ContainerTitle: typeof import('./components/ContainerTitle.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default']
LogContainer: typeof import('./components/LogContainer.vue')['default']
LogEventSource: typeof import('./components/LogEventSource.vue')['default']
LogViewer: typeof import('./components/LogViewer.vue')['default']
LogViewerWithSource: typeof import('./components/LogViewerWithSource.vue')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiLightChevronDoubleDown: typeof import('~icons/mdi-light/chevron-double-down')['default']
MdiLightChevronLeft: typeof import('~icons/mdi-light/chevron-left')['default']
MdiLightChevronRight: typeof import('~icons/mdi-light/chevron-right')['default']
MdiLightCog: typeof import('~icons/mdi-light/cog')['default']
MdiLightMagnify: typeof import('~icons/mdi-light/magnify')['default']
MobileMenu: typeof import('./components/MobileMenu.vue')['default']
OcticonContainer24: typeof import('~icons/octicon/container24')['default']
OcticonDownload24: typeof import('~icons/octicon/download24')['default']
OcticonTrash24: typeof import('~icons/octicon/trash24')['default']
PastTime: typeof import('./components/PastTime.vue')['default']
RelativeTime: typeof import('./components/RelativeTime.vue')['default']
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default']
}
}
export { }

View File

@@ -4,37 +4,44 @@
{{ state }}
</div>
<div class="column is-narrow" v-if="stat.memoryUsage !== null">
<span class="has-text-weight-light">mem</span>
<span class="has-text-weight-light has-spacer">mem</span>
<span class="has-text-weight-bold">
{{ formatBytes(stat.memoryUsage) }}
</span>
</div>
<div class="column is-narrow" v-if="stat.cpu !== null">
<span class="has-text-weight-light">load</span>
<span class="has-text-weight-light has-spacer">load</span>
<span class="has-text-weight-bold"> {{ stat.cpu }}% </span>
</div>
</div>
</template>
<script>
export default {
props: {
stat: Object,
state: String,
<script lang="ts" setup>
import { ContainerStat } from "@/types/Container";
import { PropType } from "vue";
defineProps({
stat: {
type: Object as PropType<ContainerStat>,
required: true,
},
name: "ContainerStat",
methods: {
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
},
},
};
state: String,
});
function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.has-spacer {
&::after {
content: " ";
}
}
</style>

View File

@@ -1,18 +1,16 @@
<template>
<div class="columns is-marginless has-text-weight-bold is-family-monospace">
<span class="column is-ellipsis"
>{{ container.name }} <span class="tag is-dark">{{ container.image }}</span></span
>
<span class="column is-ellipsis">
{{ container.name }}
<span class="tag is-dark">{{ container.image.replace(/@sha.*/, "") }}</span>
</span>
</div>
</template>
<script>
export default {
props: {
container: Object,
},
name: "ContainerTitle",
};
<script lang="ts" setup>
defineProps({
container: Object,
});
</script>
<style lang="scss" scoped></style>

View File

@@ -1,9 +1,9 @@
<template>
<div class="panel">
<b-autocomplete
<o-autocomplete
ref="autocomplete"
v-model="query"
placeholder="Search containers using ⌘ + k, ⌃k"
placeholder="Search containers using ⌘ + k or ctrl + k"
field="name"
open-on-focus
keep-first
@@ -11,101 +11,94 @@
:data="results"
@select="selected"
>
<template slot-scope="props">
<template #default="props">
<div class="media">
<div class="media-left">
<span class="icon is-small" :class="props.option.state"><icon name="crate"></icon></span>
<span class="icon is-small" :class="props.option.state">
<octicon-container-24 />
</span>
</div>
<div class="media-content">
{{ props.option.name }}
</div>
<div class="media-right">
<span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column">
<icon name="column"></icon>
<cil-columns />
</span>
</div>
</div>
</template>
</b-autocomplete>
</o-autocomplete>
</div>
</template>
<script>
import { mapState, mapActions } from "vuex";
<script lang="ts" setup>
import fuzzysort from "fuzzysort";
import { computed, nextTick, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useContainerStore } from "@/stores/container";
import { storeToRefs } from "pinia";
import { Container } from "@/types/Container";
import PastTime from "./PastTime";
import Icon from "./Icon";
const props = defineProps({
maxResults: {
default: 20,
type: Number,
},
});
export default {
props: {
maxResults: {
default: 20,
type: Number,
},
},
data() {
return {
query: "",
};
},
name: "FuzzySearchModal",
components: {
Icon,
PastTime,
},
mounted() {
this.$nextTick(() => this.$refs.autocomplete.focus());
},
watch: {},
methods: {
...mapActions({
appendActiveContainer: "APPEND_ACTIVE_CONTAINER",
}),
selected(item) {
this.$router.push({ name: "container", params: { id: item.id, name: item.name } });
this.$emit("close");
},
addColumn(container) {
this.appendActiveContainer(container);
this.$emit("close");
},
},
computed: {
...mapState(["containers"]),
preparedContainers() {
return this.containers.map((c) => ({
name: c.name,
id: c.id,
created: c.created,
state: c.state,
preparedName: fuzzysort.prepare(c.name),
}));
},
results() {
const options = {
limit: this.maxResults,
key: "preparedName",
};
if (this.query) {
const results = fuzzysort.go(this.query, this.preparedContainers, options);
results.forEach((result) => {
if (result.obj.state === "running") {
result.score += 1;
}
});
return results.sort((a, b) => b.score - a.score).map((i) => i.obj);
} else {
return [...this.containers].sort((a, b) => b.created - a.created);
const emit = defineEmits(["close"]);
const query = ref("");
const autocomplete = ref<HTMLElement>();
const router = useRouter();
const store = useContainerStore();
const { containers } = storeToRefs(store);
const preparedContainers = computed(() =>
containers.value.map(({ name, id, created, state }) => ({
name,
id,
created,
state,
preparedName: fuzzysort.prepare(name),
}))
);
const results = computed(() => {
const options = {
limit: props.maxResults,
key: "preparedName",
};
if (query.value) {
const results = fuzzysort.go(query.value, preparedContainers.value, options);
results.forEach((result) => {
if (result.obj.state === "running") {
// @ts-ignore
result.score += 1;
}
},
},
};
});
return [...results].sort((a, b) => b.score - a.score).map((i) => i.obj);
} else {
return [...containers.value].sort((a, b) => b.created - a.created);
}
});
onMounted(() => nextTick(() => autocomplete.value?.focus()));
function selected(item: { id: string; name: string }) {
router.push({ name: "container", params: { id: item.id, name: item.name } });
emit("close");
}
function addColumn(container: Container) {
store.appendActiveContainer(container);
emit("close");
}
</script>
<style lang="scss" scoped>
.panel {
min-height: 400px;
width: 580px;
}
.running {
@@ -122,7 +115,7 @@ export default {
}
}
::v-deep a.dropdown-item {
:deep(a.dropdown-item) {
padding-right: 1em;
.media-right {
visibility: hidden;
@@ -131,4 +124,8 @@ export default {
visibility: visible;
}
}
.icon {
vertical-align: middle;
}
</style>

View File

@@ -1,32 +0,0 @@
<template functional>
<svg class="icomoon" :class="['icon-' + props.name]">
<use :href="'#icon-' + props.name"></use>
</svg>
</template>
<script>
export default {
props: {
name: {
required: true,
type: String,
},
},
name: "Icon",
};
</script>
<style lang="scss" scoped>
.icomoon {
display: inline-block;
width: 1em;
height: 1em;
stroke-width: 0;
stroke: currentColor;
fill: currentColor;
.icon:not(.keep-size) & {
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="observer" class="infinte-loader">
<div ref="root" class="infinte-loader">
<div class="spinner" v-show="isLoading">
<div class="bounce1"></div>
<div class="bounce2"></div>
@@ -8,40 +8,34 @@
</div>
</template>
<script>
export default {
name: "InfiniteLoader",
data() {
return {
isLoading: false,
};
},
props: {
onLoadMore: Function,
enabled: Boolean,
},
mounted() {
const intersectionObserver = new IntersectionObserver(
async (entries) => {
if (entries[0].intersectionRatio <= 0) return;
if (this.onLoadMore && this.enabled) {
const scrollingParent = this.$el.closest("[data-scrolling]") || document.documentElement;
const previousHeight = scrollingParent.scrollHeight;
this.isLoading = true;
await this.onLoadMore();
this.isLoading = false;
this.$nextTick(() => (scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight));
}
},
{ threshholds: 1 }
);
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
intersectionObserver.observe(this.$refs.observer);
const props = defineProps({
onLoadMore: Function,
enabled: Boolean,
});
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
},
};
const isLoading = ref(false);
const root = ref<HTMLElement>();
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].intersectionRatio <= 0) return;
if (props.onLoadMore && props.enabled) {
const scrollingParent = root.value.closest("[data-scrolling]") || document.documentElement;
const previousHeight = scrollingParent.scrollHeight;
isLoading.value = true;
await props.onLoadMore();
isLoading.value = false;
await nextTick();
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
}
});
onMounted(() => observer.observe(root.value));
onUnmounted(() => observer.disconnect());
</script>
<style scoped lang="scss">
.infinte-loader {
min-height: 1px;

View File

@@ -0,0 +1,120 @@
<template>
<div class="dropdown is-right is-hoverable">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span class="icon">
<mdi-dots-vertical />
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" @click="onClearClicked">
<div class="level is-justify-content-start">
<div class="level-left">
<div class="level-item">
<octicon-trash-24 class="mr-4" />
</div>
</div>
<div class="level-right">
<div class="level-item">Clear</div>
</div>
</div>
</a>
<a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}`">
<div class="level is-justify-content-start">
<div class="level-left">
<div class="level-item">
<octicon-download-24 class="mr-4" />
</div>
</div>
<div class="level-right">
<div class="level-item">Download</div>
</div>
</div>
</a>
<hr class="dropdown-divider" />
<a class="dropdown-item" @click="showSearch = true">
<div class="level is-justify-content-start">
<div class="level-left">
<div class="level-item">
<mdi-light-magnify class="mr-4" />
</div>
</div>
<div class="level-right">
<div class="level-item">Search</div>
</div>
</div>
</a>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, PropType } from "vue";
import hotkeys from "hotkeys-js";
import config from "@/stores/config";
import { Container } from "@/types/Container";
import { useSearchFilter } from "@/composables/search";
const { showSearch } = useSearchFilter();
const { base } = config;
const props = defineProps({
onClearClicked: {
type: Function as PropType<(e: Event) => void>,
default: (e: Event) => {},
},
container: {
type: Object as () => Container,
required: true,
},
});
const onHotkey = (event: Event) => {
props.onClearClicked(event);
event.preventDefault();
};
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
</script>
<style lang="scss" scoped>
#download.button,
#clear.button {
.icon {
height: 80%;
}
&:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
.toolbar {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 200px;
background-color: var(--action-toolbar-background-color);
border-radius: 8em;
margin-top: 0.5em;
& > div {
margin: 0 2em;
padding: 0.5em 0;
}
.button {
background-color: rgba(0, 0, 0, 0) !important;
&.is-small {
font-size: 0.65rem;
}
}
}
</style>

View File

@@ -1,79 +1,64 @@
<template>
<scrollable-view :scrollable="scrollable" v-if="container">
<template v-slot:header v-if="showTitle">
<div class="mr-0 columns is-vcentered is-hidden-mobile">
<div class="column is-clipped">
<container-title :container="container" @close="$emit('close')"></container-title>
<template #header v-if="showTitle">
<div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile">
<div class="column is-clipped is-paddingless">
<container-title :container="container" @close="$emit('close')" />
</div>
<div class="column is-clipped">
<container-stat :stat="container.stat" :state="container.state"></container-stat>
<div class="column is-narrow is-paddingless">
<container-stat :stat="container.stat" :state="container.state" v-if="container.stat" />
</div>
<div class="column is-narrow">
<a
class="button is-small is-outlined"
id="download"
:href="`${base}/api/logs/download?id=${container.id}`"
download
>
<span class="icon">
<icon name="save"></icon>
</span>
Download
</a>
<div class="mr-2 column is-narrow is-paddingless">
<log-actions-toolbar :container="container" :onClearClicked="onClearClicked" />
</div>
<div class="column is-narrow" v-if="closable">
<button class="delete is-medium" @click="$emit('close')"></button>
<div class="mr-2 column is-narrow is-paddingless" v-if="closable">
<button class="delete is-medium" @click="emit('close')"></button>
</div>
</div>
</template>
<template v-slot="{ setLoading }">
<log-viewer-with-source :id="id" @loading-more="setLoading($event)"></log-viewer-with-source>
<template #default="{ setLoading }">
<log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)" />
</template>
</scrollable-view>
</template>
<script>
import LogViewerWithSource from "./LogViewerWithSource";
import ScrollableView from "./ScrollableView";
import ContainerTitle from "./ContainerTitle";
import ContainerStat from "./ContainerStat";
import Icon from "./Icon";
import config from "../store/config";
import containerMixin from "./mixins/container";
<script lang="ts" setup>
import { ref, toRefs } from "vue";
import LogViewerWithSource from "./LogViewerWithSource.vue";
import { useContainerStore } from "@/stores/container";
export default {
mixins: [containerMixin],
props: {
id: {
type: String,
},
showTitle: {
type: Boolean,
default: false,
},
scrollable: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: false,
},
const props = defineProps({
id: {
type: String,
required: true,
},
name: "LogContainer",
components: {
LogViewerWithSource,
ScrollableView,
ContainerTitle,
ContainerStat,
Icon,
showTitle: {
type: Boolean,
default: false,
},
computed: {
base() {
return config.base;
},
scrollable: {
type: Boolean,
default: false,
},
};
closable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close"]);
const { id } = toRefs(props);
const store = useContainerStore();
const container = store.currentContainer(id);
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
function onClearClicked() {
viewer.value?.clear();
}
</script>
<style lang="scss" scoped>
button.delete {
@@ -88,16 +73,4 @@ button.delete {
opacity: 1;
}
}
#download.button {
.icon {
margin-right: 5px;
height: 80%;
}
&:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
</style>

View File

@@ -1,10 +1,14 @@
import { mount } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing";
// @ts-ignore
import EventSource, { sources } from "eventsourcemock";
import debounce from "lodash.debounce";
import EventSource from "eventsourcemock";
import { sources } from "eventsourcemock";
import { shallowMount, mount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import LogEventSource from "./LogEventSource.vue";
import LogViewer from "./LogViewer.vue";
import { settings } from "../composables/settings";
import { useSearchFilter } from "@/composables/search";
import { mocked } from "ts-jest/utils";
import { computed, Ref } from "vue";
jest.mock("lodash.debounce", () =>
jest.fn((fn) => {
@@ -13,69 +17,77 @@ jest.mock("lodash.debounce", () =>
})
);
jest.mock("../store/config.js", () => ({ base: "" }));
describe("<LogEventSource />", () => {
beforeEach(() => {
global.EventSource = EventSource;
window.scrollTo = jest.fn();
const observe = jest.fn();
const disconnect = jest.fn();
global.IntersectionObserver = jest.fn(() => ({
observe,
disconnect,
}));
debounce.mockClear();
});
function createLogEventSource({ hourStyle = "auto", searchFilter = null } = {}) {
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.component("log-viewer", LogViewer);
const state = { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } };
const getters = {
allContainersById() {
return {
abc: { state: "running" },
};
jest.mock("@/stores/container", () => ({
__esModule: true,
useContainerStore() {
return {
currentContainer(id: Ref<string>) {
return computed(() => ({ id: id.value }));
},
};
},
}));
const store = new Vuex.Store({
state,
getters,
});
jest.mock("@/stores/config", () => ({ base: "" }));
describe("<LogEventSource />", () => {
const search = useSearchFilter();
beforeEach(() => {
// @ts-ignore
global.EventSource = EventSource;
window.scrollTo = jest.fn();
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
disconnect: jest.fn(),
}));
mocked(debounce).mockClear();
jest.resetModules();
});
function createLogEventSource(
{
searchFilter = undefined,
hourStyle = "auto",
}: { searchFilter?: string | undefined; hourStyle?: "auto" | "24" | "12" } = {
hourStyle: "auto",
}
) {
settings.value.hourStyle = hourStyle;
search.searchFilter.value = searchFilter;
return mount(LogEventSource, {
localVue,
store,
scopedSlots: {
global: {
plugins: [createTestingPinia()],
components: {
LogViewer,
},
},
slots: {
default: `
<log-viewer :messages="props.messages"></log-viewer>
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
`,
},
propsData: { id: "abc" },
props: { id: "abc" },
});
}
test("renders correctly", async () => {
const wrapper = createLogEventSource();
expect(wrapper.element).toMatchSnapshot();
expect(wrapper.html()).toMatchSnapshot();
});
test("should connect to EventSource", async () => {
const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
wrapper.destroy();
wrapper.unmount();
});
test("should close EventSource", async () => {
const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
wrapper.destroy();
wrapper.unmount();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
});
@@ -121,7 +133,7 @@ describe("<LogEventSource />", () => {
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
});
const [message, _] = wrapper.findComponent(LogViewer).vm.messages;
const [message, _] = wrapper.getComponent(LogViewer).vm.messages;
const { key, ...messageWithoutKey } = message;
@@ -138,8 +150,10 @@ describe("<LogEventSource />", () => {
describe("render html correctly", () => {
const RealDate = Date;
beforeAll(() => {
// @ts-ignore
global.Date = class extends RealDate {
constructor(arg) {
constructor(arg: any | number) {
super(arg);
if (arg) {
return new RealDate(arg);
} else {
@@ -158,11 +172,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">"This is a message."</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">\\"This is a message.\\"</span></li></ul>"`
);
});
test("should render messages with color", async () => {
@@ -173,11 +185,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text"><span style="color:#000">black<span style="color:#AAA">white</span></span></span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span></li></ul>"`
);
});
test("should render messages with html entities", async () => {
@@ -188,11 +198,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">&lt;test&gt;foo bar&lt;/test&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">&lt;test&gt;foo bar&lt;/test&gt;</span></li></ul>"`
);
});
test("should render dates with 12 hour style", async () => {
@@ -203,11 +211,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 11:55:42 PM</time></span> <span class="text">&lt;test&gt;foo bar&lt;/test&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\">today at 11:55:42 PM</time></span><span class=\\"text\\">&lt;test&gt;foo bar&lt;/test&gt;</span></li></ul>"`
);
});
test("should render dates with 24 hour style", async () => {
@@ -218,11 +224,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 23:55:42</time></span> <span class="text">&lt;test&gt;foo bar&lt;/test&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\">today at 23:55:42</time></span><span class=\\"text\\">&lt;test&gt;foo bar&lt;/test&gt;</span></li></ul>"`
);
});
test("should render messages with filter", async () => {
@@ -236,11 +240,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">This is a <mark>test</mark> &lt;hi&gt;&lt;/hi&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">This is a <mark>test</mark> &lt;hi&gt;&lt;/hi&gt;</span></li></ul>"`
);
});
});
});

View File

@@ -1,122 +1,133 @@
<template>
<div>
<infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader>
<slot :messages="messages"></slot>
</div>
<infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader>
<slot :messages="messages"></slot>
</template>
<script>
<script lang="ts" setup>
import { toRefs, ref, watch, onUnmounted } from "vue";
import debounce from "lodash.debounce";
import InfiniteLoader from "./InfiniteLoader";
import config from "../store/config";
import containerMixin from "./mixins/container";
export default {
props: ["id"],
mixins: [containerMixin],
name: "LogEventSource",
components: {
InfiniteLoader,
},
data() {
return {
messages: [],
buffer: [],
es: null,
lastEventId: null,
};
},
created() {
this.flushBuffer = debounce(this.flushNow, 250, { maxWait: 1000 });
this.loadLogs();
},
beforeDestroy() {
this.es.close();
},
methods: {
loadLogs() {
this.reset();
this.connect();
},
onContainerStopped() {
this.es.close();
this.buffer.push({ event: "container-stopped", message: "Container stopped", date: new Date(), key: new Date() });
this.flushBuffer();
this.flushBuffer.flush();
},
onMessage(e) {
this.lastEventId = e.lastEventId;
this.buffer.push(this.parseMessage(e.data));
this.flushBuffer();
},
onContainerStateChange(newValue, oldValue) {
if (newValue == "running" && newValue != oldValue) {
this.buffer.push({
event: "container-started",
message: "Container started",
date: new Date(),
key: new Date(),
});
this.connect();
}
},
connect() {
this.es = new EventSource(`${config.base}/api/logs/stream?id=${this.id}&lastEventId=${this.lastEventId ?? ""}`);
this.es.addEventListener("container-stopped", (e) => this.onContainerStopped());
this.es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
this.es.onmessage = (e) => this.onMessage(e);
},
flushNow() {
this.messages.push(...this.buffer);
this.buffer = [];
},
reset() {
if (this.es) {
this.es.close();
}
this.flushBuffer.cancel();
this.es = null;
this.messages = [];
this.buffer = [];
this.lastEventId = null;
},
async loadOlderLogs() {
if (this.messages.length < 300) return;
import { LogEntry } from "@/types/LogEntry";
import InfiniteLoader from "./InfiniteLoader.vue";
import config from "@/stores/config";
import { useContainerStore } from "@/stores/container";
this.$emit("loading-more", true);
const to = this.messages[0].date;
const last = this.messages[299].date;
const delta = to - last;
const from = new Date(to.getTime() + delta);
const logs = await (
await fetch(`${config.base}/api/logs?id=${this.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
).text();
if (logs) {
const newMessages = logs
.trim()
.split("\n")
.map((line) => this.parseMessage(line));
this.messages.unshift(...newMessages);
}
this.$emit("loading-more", false);
},
parseMessage(data) {
let i = data.indexOf(" ");
if (i == -1) {
i = data.length;
}
const key = data.substring(0, i);
const date = new Date(key);
const message = data.substring(i + 1);
return { key, date, message };
},
const props = defineProps({
id: {
type: String,
required: true,
},
watch: {
id(newValue, oldValue) {
if (oldValue !== newValue) {
this.loadLogs();
}
},
},
};
});
const { id } = toRefs(props);
const emit = defineEmits(["loading-more"]);
const store = useContainerStore();
const container = store.currentContainer(id);
const messages = ref<LogEntry[]>([]);
const buffer = ref<LogEntry[]>([]);
function flushNow() {
messages.value.push(...buffer.value);
buffer.value = [];
}
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
let es: EventSource | null = null;
let lastEventId = "";
function connect({ clear } = { clear: true }) {
es?.close();
if (clear) {
flushBuffer.cancel();
messages.value = [];
buffer.value = [];
lastEventId = "";
}
es = new EventSource(`${config.base}/api/logs/stream?id=${props.id}&lastEventId=${lastEventId}`);
es.addEventListener("container-stopped", () => {
es?.close();
es = null;
buffer.value.push({
event: "container-stopped",
message: "Container stopped",
date: new Date(),
key: new Date().toString(),
});
flushBuffer();
flushBuffer.flush();
});
es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
es.onmessage = (e) => {
lastEventId = e.lastEventId;
if (e.data) {
buffer.value.push(parseMessage(e.data));
flushBuffer();
}
};
}
async function loadOlderLogs() {
if (messages.value.length < 300) return;
emit("loading-more", true);
const to = messages.value[0].date;
const last = messages.value[299].date;
const delta = to.getTime() - last.getTime();
const from = new Date(to.getTime() + delta);
const logs = await (
await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
).text();
if (logs) {
const newMessages = logs
.trim()
.split("\n")
.map((line) => parseMessage(line));
messages.value.unshift(...newMessages);
}
emit("loading-more", false);
}
function parseMessage(data: String): LogEntry {
let i = data.indexOf(" ");
if (i == -1) {
i = data.length;
}
const key = data.substring(0, i);
const date = new Date(key);
const message = data.substring(i + 1);
return { key, date, message };
}
watch(
() => container.value.state,
(newValue, oldValue) => {
console.log("LogEventSource: container changed", newValue, oldValue);
if (newValue == "running" && newValue != oldValue) {
buffer.value.push({
event: "container-started",
message: "Container started",
date: new Date(),
key: new Date().toString(),
});
connect({ clear: false });
}
}
);
onUnmounted(() => {
if (es) {
es.close();
}
});
connect();
watch(id, () => connect());
defineExpose({
clear: () => (messages.value = []),
});
</script>

View File

@@ -1,65 +1,33 @@
<template>
<ul class="events" :class="settings.size">
<ul class="events" :class="size">
<li v-for="item in filtered" :key="item.key" :data-event="item.event">
<span class="date" v-if="settings.showTimestamp"><relative-time :date="item.date"></relative-time></span>
<span class="date" v-if="showTimestamp"> <relative-time :date="item.date"></relative-time></span>
<span class="text" v-html="colorize(item.message)"></span>
</li>
</ul>
</template>
<script>
import { mapState } from "vuex";
<script lang="ts" setup>
import { PropType, toRefs } from "vue";
import { size, showTimestamp } from "@/composables/settings";
import RelativeTime from "./RelativeTime.vue";
import AnsiConvertor from "ansi-to-html";
import DOMPurify from "dompurify";
import RelativeTime from "./RelativeTime";
import { LogEntry } from "@/types/LogEntry";
import { useSearchFilter } from "@/composables/search";
const props = defineProps({
messages: {
type: Array as PropType<LogEntry[]>,
required: true,
},
});
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
if (window.trustedTypes && trustedTypes.createPolicy) {
trustedTypes.createPolicy("default", {
createHTML: (string, sink) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: true }),
});
}
export default {
props: ["messages"],
name: "LogViewer",
components: { RelativeTime },
data() {
return {
showSearch: false,
};
},
methods: {
colorize: function (value) {
return ansiConvertor.toHtml(value).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>");
},
},
computed: {
...mapState(["searchFilter", "settings"]),
filtered() {
const { searchFilter, messages } = this;
if (searchFilter) {
const isSmartCase = searchFilter === searchFilter.toLowerCase();
try {
const regex = isSmartCase ? new RegExp(searchFilter, "i") : new RegExp(searchFilter);
return messages
.filter((d) => d.message.match(regex))
.map((d) => ({
...d,
message: d.message.replace(regex, "<mark>$&</mark>"),
}));
} catch (e) {
if (e instanceof SyntaxError) {
console.info(`Ignoring SytaxError from search.`, e);
return messages;
}
throw e;
}
}
return messages;
},
},
};
const colorize = (value: string) =>
ansiConvertor.toHtml(value).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>");
const { messages } = toRefs(props);
const filtered = useSearchFilter().filteredMessages(messages);
</script>
<style scoped lang="scss">
.events {
@@ -108,9 +76,12 @@ export default {
.text {
white-space: pre-wrap;
&::before {
content: " ";
}
}
::v-deep mark {
:deep(mark) {
border-radius: 2px;
background-color: var(--secondary-color);
animation: pops 200ms ease-out;

View File

@@ -1,19 +1,26 @@
<template>
<log-event-source :id="id" v-slot="eventSource" @loading-more="$emit('loading-more', $event)">
<log-viewer :messages="eventSource.messages"></log-viewer>
<log-event-source ref="source" :id="id" #default="{ messages }" @loading-more="emit('loading-more', $event)">
<log-viewer :messages="messages"></log-viewer>
</log-event-source>
</template>
<script>
import LogEventSource from "./LogEventSource";
import LogViewer from "./LogViewer";
export default {
props: ["id"],
name: "LogViewerWithSource",
components: {
LogEventSource,
LogViewer,
<script lang="ts" setup>
import LogViewer from "./LogViewer.vue";
import { ref } from "vue";
defineProps({
id: {
type: String,
required: true,
},
};
});
const emit = defineEmits(["loading-more"]);
const source = ref<InstanceType<typeof LogViewer>>();
function clear() {
source.value?.clear();
}
defineExpose({
clear,
});
</script>

View File

@@ -9,7 +9,7 @@
</router-link>
</div>
<div class="column ml-4 is-family-monospace is-ellipsis" v-if="$route.name == 'container'">
{{ allContainersById[$route.params.id].name }}
{{ allContainersById[route.params.id].name }}
</div>
<div class="column is-narrow push-right">
@@ -41,32 +41,26 @@
</aside>
</template>
<script>
import { mapGetters } from "vuex";
<script lang="ts" setup>
import { ref, watch } from "vue";
import { useContainerStore } from "@/stores/container";
import { storeToRefs } from "pinia";
import { useRoute } from "vue-router";
export default {
props: [],
name: "MobileMenu",
data() {
return {
showNav: false,
};
},
computed: {
...mapGetters(["visibleContainers", "allContainersById"]),
},
watch: {
$route(to, from) {
this.showNav = false;
},
},
};
const store = useContainerStore();
const route = useRoute();
const { visibleContainers, allContainersById } = storeToRefs(store);
const showNav = ref(false);
watch(route, () => {
showNav.value = false;
});
</script>
<style scoped lang="scss">
aside {
padding: 1em;
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--scheme-main-ter);

View File

@@ -2,7 +2,7 @@
<time :datetime="date.toISOString()">{{ text }}</time>
</template>
<script>
<script lang="ts">
import formatDistance from "date-fns/formatDistance";
export default {
@@ -14,7 +14,7 @@ export default {
},
data() {
return {
text: "",
text: "" as string,
interval: null,
};
},

View File

@@ -1,10 +1,10 @@
<template>
<time :datetime="date.toISOString()">{{ date | relativeTime(locale) }}</time>
<time :datetime="date.toISOString()">{{ relativeTime(date, locale) }}</time>
</template>
<script>
import { mapState } from "vuex";
<script lang="ts">
import { formatRelative } from "date-fns";
import { hourStyle } from "@/composables/settings";
import enGB from "date-fns/locale/en-GB";
import enUS from "date-fns/locale/en-US";
@@ -27,11 +27,9 @@ export default {
},
name: "RelativeTime",
components: {},
computed: {
...mapState(["settings"]),
locale() {
const locale = styles[this.settings.hourStyle];
const locale = styles[hourStyle.value];
const oldFormatter = locale.formatRelative;
return {
...locale,
@@ -41,7 +39,7 @@ export default {
};
},
},
filters: {
methods: {
relativeTime(date, locale) {
return formatRelative(date, new Date(), { locale });
},

View File

@@ -1,5 +1,5 @@
<template>
<div class="scroll-progress">
<div class="scroll-progress" ref="root">
<svg width="100" height="100" viewBox="0 0 100 100" :class="{ indeterminate }">
<circle r="44" cx="50" cy="50" :style="{ '--progress': scrollProgress }" />
</svg>
@@ -17,79 +17,76 @@
</div>
</template>
<script>
import { mapGetters } from "vuex";
<script lang="ts" setup>
import { useContainerStore } from "@/stores/container";
import throttle from "lodash.throttle";
import { storeToRefs } from "pinia";
import { onMounted, onUnmounted, ref, watchPostEffect } from "vue";
export default {
name: "ScrollProgress",
props: {
indeterminate: {
default: false,
type: Boolean,
},
autoHide: {
default: true,
type: Boolean,
},
const props = defineProps({
indeterminate: {
default: false,
type: Boolean,
},
data() {
return {
scrollProgress: 0,
animation: { cancel: () => {} },
parentElement: document,
};
autoHide: {
default: true,
type: Boolean,
},
created() {
this.onScrollThrottled = throttle(this.onScroll, 150);
},
mounted() {
this.attachEvents();
this.$once("hook:beforeDestroy", this.detachEvents);
},
watch: {
activeContainers() {
this.detachEvents();
this.attachEvents();
},
indeterminate() {
this.$nextTick(() => this.onScroll());
},
},
computed: {
...mapGetters(["activeContainers"]),
},
methods: {
attachEvents() {
this.parentElement = this.$el.closest("[data-scrolling]") || document;
this.parentElement.addEventListener("scroll", this.onScrollThrottled);
},
detachEvents() {
this.parentElement.removeEventListener("scroll", this.onScrollThrottled);
},
onScroll() {
const p = this.parentElement == document ? document.documentElement : this.parentElement;
this.scrollProgress = p.scrollTop / (p.scrollHeight - p.clientHeight);
this.animation.cancel();
if (this.autoHide) {
this.animation = this.$el.animate(
{ opacity: [1, 0] },
{
duration: 500,
delay: 2000,
fill: "both",
easing: "ease-out",
}
);
});
const scrollProgress = ref(0);
const animation = ref({ cancel: () => {} });
const parentElement = ref<Node>(document);
const root = ref<HTMLElement>();
const store = useContainerStore();
const { activeContainers } = storeToRefs(store);
const onScrollThrottled = throttle(onScroll, 150);
function onScroll() {
const parent = parentElement.value == document ? document.documentElement : (parentElement.value as HTMLElement);
scrollProgress.value = parent.scrollTop / (parent.scrollHeight - parent.clientHeight);
animation.value.cancel();
if (props.autoHide && root.value) {
animation.value = root.value.animate(
{ opacity: [1, 0] },
{
duration: 500,
delay: 2000,
fill: "both",
easing: "ease-out",
}
},
},
};
);
}
}
function attachEvents() {
parentElement.value = root.value?.closest("[data-scrolling]") || document;
parentElement.value.addEventListener("scroll", onScrollThrottled);
}
function detachEvents() {
parentElement.value.removeEventListener("scroll", onScrollThrottled);
}
onMounted(() => {
attachEvents();
});
onUnmounted(() => {
detachEvents();
});
watchPostEffect(() => {
activeContainers.value.length;
detachEvents();
attachEvents();
});
</script>
<style scoped lang="scss">
.scroll-progress {
display: inline-block;
position: relative;
pointer-events: none;
svg {
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.2));

View File

@@ -3,7 +3,7 @@
<header v-if="$slots.header">
<slot name="header"></slot>
</header>
<main ref="content" :data-scrolling="scrollable">
<main ref="content" :data-scrolling="scrollable ? true : undefined">
<div class="is-scrollbar-progress is-hidden-mobile">
<scroll-progress v-show="paused" :indeterminate="loading" :auto-hide="!loading"></scroll-progress>
</div>
@@ -14,17 +14,14 @@
<div class="is-scrollbar-notification">
<transition name="fade">
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
<icon name="chevrons-down"></icon>
<mdi-light-chevron-double-down />
</button>
</transition>
</div>
</section>
</template>
<script>
import Icon from "./Icon";
import ScrollProgress from "./ScrollProgress";
<script lang="ts">
export default {
props: {
scrollable: {
@@ -32,21 +29,20 @@ export default {
default: true,
},
},
components: {
Icon,
ScrollProgress,
},
name: "ScrollableView",
data() {
return {
paused: false,
hasMore: false,
loading: false,
mutationObserver: null,
intersectionObserver: null,
};
},
mounted() {
const { content } = this.$refs;
const mutationObserver = new MutationObserver((e) => {
this.mutationObserver = new MutationObserver((e) => {
if (!this.paused) {
this.scrollToBottom("instant");
} else {
@@ -58,17 +54,18 @@ export default {
}
}
});
mutationObserver.observe(content, { childList: true, subtree: true });
this.$once("hook:beforeDestroy", () => mutationObserver.disconnect());
this.mutationObserver.observe(content, { childList: true, subtree: true });
const intersectionObserver = new IntersectionObserver(
this.intersectionObserver = new IntersectionObserver(
(entries) => (this.paused = entries[0].intersectionRatio == 0),
{ threshholds: [0, 1], rootMargin: "80px 0px" }
);
intersectionObserver.observe(this.$refs.scrollObserver);
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
this.intersectionObserver.observe(this.$refs.scrollObserver);
},
beforeUnmount() {
this.mutationObserver.disconnect();
this.intersectionObserver.disconnect();
},
methods: {
scrollToBottom(behavior = "instant") {
this.$refs.scrollObserver.scrollIntoView({ behavior });

View File

@@ -1,17 +1,17 @@
<template>
<div class="search columns is-gapless is-vcentered" v-show="showSearch" v-if="settings.search">
<div class="search columns is-gapless is-vcentered" v-show="showSearch" v-if="search">
<div class="column">
<p class="control has-icons-left">
<input
class="input"
type="text"
placeholder="Find / RegEx"
ref="filter"
v-model="filter"
ref="input"
v-model="searchFilter"
@keyup.esc="resetSearch()"
/>
<span class="icon is-left">
<icon name="search"></icon>
<mdi-light-magnify />
</span>
</p>
</div>
@@ -21,57 +21,36 @@
</div>
</template>
<script>
import { mapActions, mapState } from "vuex";
<script lang="ts" setup>
import hotkeys from "hotkeys-js";
import Icon from "./Icon";
export default {
props: [],
name: "Search",
components: {
Icon,
},
data() {
return {
showSearch: false,
};
},
mounted() {
hotkeys("command+f, ctrl+f", (event, handler) => {
this.showSearch = true;
this.$nextTick(() => this.$refs.filter.focus() || this.$refs.filter.select());
event.preventDefault();
});
hotkeys("esc", (event, handler) => {
this.resetSearch();
});
},
beforeDestroy() {
this.updateSearchFilter("");
hotkeys.unbind("command+f, ctrl+f, esc");
},
methods: {
...mapActions({
updateSearchFilter: "SET_SEARCH",
}),
resetSearch() {
this.showSearch = false;
this.filter = "";
},
},
computed: {
...mapState(["searchFilter", "settings"]),
filter: {
get() {
return this.searchFilter;
},
set(value) {
this.updateSearchFilter(value);
},
},
},
};
import { search } from "@/composables/settings";
import { useSearchFilter } from "@/composables/search";
import { ref, nextTick, onMounted, onUnmounted } from "vue";
const input = ref<HTMLInputElement>();
const { searchFilter, showSearch } = useSearchFilter();
onMounted(() => {
hotkeys("command+f, ctrl+f", (event, handler) => {
showSearch.value = true;
nextTick(() => input.value?.focus() || input.value?.select());
event.preventDefault();
});
hotkeys("esc", () => resetSearch());
});
onUnmounted(() => {
searchFilter.value = "";
showSearch.value = false;
hotkeys.unbind("command+f, ctrl+f");
hotkeys.unbind("esc");
});
function resetSearch() {
searchFilter.value = "";
showSearch.value = false;
}
</script>
<style lang="scss" scoped>

View File

@@ -9,24 +9,16 @@
</router-link>
</div>
<div class="column is-narrow has-text-right px-1">
<button
class="button is-small is-rounded is-settings-control"
@click="$emit('search')"
title="Search containers (⌘ + k, ⌃k)"
>
<button class="button is-rounded" @click="$emit('search')" title="Search containers (⌘ + k, ⌃k)">
<span class="icon">
<icon name="search"></icon>
<mdi-light-magnify />
</span>
</button>
</div>
<div class="column is-narrow has-text-right px-0">
<router-link
:to="{ name: 'settings' }"
active-class="is-active"
class="button is-small is-rounded is-settings-control"
>
<router-link :to="{ name: 'settings' }" active-class="is-active" class="button is-rounded">
<span class="icon">
<icon name="cog"></icon>
<mdi-light-cog />
</span>
</router-link>
</div>
@@ -46,11 +38,11 @@
<div class="is-flex-shrink-1 column-icon">
<span
class="icon is-small"
@click.stop.prevent="appendActiveContainer(item)"
@click.stop.prevent="store.appendActiveContainer(item)"
v-show="!activeContainersById[item.id]"
title="Pin as column"
>
<icon name="column"></icon>
<cil-columns />
</span>
</div>
</div>
@@ -60,35 +52,22 @@
</aside>
</template>
<script>
import { mapActions, mapGetters, mapState } from "vuex";
<script lang="ts" setup>
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useContainerStore } from "@/stores/container";
import type { Container } from "@/types/Container";
import Icon from "./Icon";
const store = useContainerStore();
export default {
props: [],
name: "SideMenu",
components: {
Icon,
},
data() {
return {};
},
computed: {
...mapGetters(["visibleContainers", "activeContainers"]),
activeContainersById() {
return this.activeContainers.reduce((map, obj) => {
map[obj.id] = obj;
return map;
}, {});
},
},
methods: {
...mapActions({
appendActiveContainer: "APPEND_ACTIVE_CONTAINER",
}),
},
};
const { activeContainers, visibleContainers } = storeToRefs(store);
const activeContainersById = computed(() =>
activeContainers.value.reduce((acc, item) => {
acc[item.id] = item;
return acc;
}, {} as Record<string, Container>)
);
</script>
<style scoped lang="scss">
aside {
@@ -116,6 +95,10 @@ li.exited a {
.menu-list li {
.column-icon {
visibility: hidden;
& > span {
vertical-align: middle;
}
}
&:hover .column-icon {

View File

@@ -1,30 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LogEventSource /> renders correctly 1`] = `
<div>
<div
class="infinte-loader"
>
<div
class="spinner"
style="display: none;"
>
<div
class="bounce1"
/>
<div
class="bounce2"
/>
<div
class="bounce3"
/>
</div>
</div>
<ul
class="events medium"
/>
</div>
`;

View File

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LogEventSource /> renders correctly 1`] = `
"<div class=\\"infinte-loader\\">
<div class=\\"spinner\\" style=\\"display: none;\\">
<div class=\\"bounce1\\"></div>
<div class=\\"bounce2\\"></div>
<div class=\\"bounce3\\"></div>
</div>
</div>
<ul class=\\"events medium\\"></ul>"
`;

View File

@@ -1,19 +0,0 @@
import { mapGetters } from "vuex";
export default {
computed: {
...mapGetters(["allContainersById"]),
container() {
return this.allContainersById[this.id];
},
},
watch: {
["container.state"](newValue, oldValue) {
if (newValue == "running" && newValue != oldValue) {
this.onContainerStateChange(newValue, oldValue);
}
},
},
methods: {
onContainerStateChange(newValue, oldValue) {},
},
};

View File

@@ -0,0 +1,3 @@
import { useMediaQuery } from "@vueuse/core";
export const isMobile = useMediaQuery("(max-width: 770px)");

View File

@@ -0,0 +1,39 @@
import { ref, computed, Ref } from "vue";
const searchFilter = ref<string>();
const showSearch = ref(false);
import type { LogEntry } from "@/types/LogEntry";
export function useSearchFilter() {
function filteredMessages(messages: Ref<LogEntry[]>) {
return computed(() => {
if (searchFilter && searchFilter.value) {
const isSmartCase = searchFilter.value === searchFilter.value.toLowerCase();
try {
const regex = isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value);
return messages.value
.filter((d) => d.message.match(regex))
.map((d) => ({
...d,
message: d.message.replace(regex, "<mark>$&</mark>"),
}));
} catch (e) {
if (e instanceof SyntaxError) {
console.info(`Ignoring SytaxError from search.`, e);
return messages.value;
}
throw e;
}
}
return messages.value;
});
}
return {
filteredMessages,
searchFilter,
showSearch
};
}

View File

@@ -0,0 +1,65 @@
import { useStorage } from "@vueuse/core";
import { computed } from "vue";
export const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS";
export const DEFAULT_SETTINGS: {
search: boolean;
size: "small" | "medium" | "large";
menuWidth: number;
smallerScrollbars: boolean;
showTimestamp: boolean;
showAllContainers: boolean;
lightTheme: boolean;
hourStyle: "auto" | "24" | "12";
} = {
search: true,
size: "medium",
menuWidth: 15,
smallerScrollbars: false,
showTimestamp: true,
showAllContainers: false,
lightTheme: false,
hourStyle: "auto",
};
export const settings = useStorage(DOZZLE_SETTINGS_KEY, DEFAULT_SETTINGS);
export const search = computed({
get: () => settings.value.search,
set: (value) => (settings.value.search = value),
});
export const size = computed({
get: () => settings.value.size,
set: (value) => (settings.value.size = value),
});
export const menuWidth = computed({
get: () => settings.value.menuWidth,
set: (value) => (settings.value.menuWidth = value),
});
export const smallerScrollbars = computed({
get: () => settings.value.smallerScrollbars,
set: (value) => (settings.value.smallerScrollbars = value),
});
export const showTimestamp = computed({
get: () => settings.value.showTimestamp,
set: (value) => (settings.value.showTimestamp = value),
});
export const showAllContainers = computed({
get: () => settings.value.showAllContainers,
set: (value) => (settings.value.showAllContainers = value),
});
export const lightTheme = computed({
get: () => settings.value.lightTheme,
set: (value) => (settings.value.lightTheme = value),
});
export const hourStyle = computed({
get: () => settings.value.hourStyle,
set: (value) => (settings.value.hourStyle = value),
});

View File

@@ -0,0 +1,12 @@
import { useTitle } from "@vueuse/core";
import { ref, computed } from "vue";
const subtitle = ref("");
const title = computed(() => `${subtitle.value} - Dozzle`);
useTitle(title);
export function setTitle(t: string) {
subtitle.value = t;
}

View File

@@ -1,72 +0,0 @@
import Vue from "vue";
import VueRouter from "vue-router";
import Meta from "vue-meta";
import Switch from "buefy/dist/esm/switch";
import Radio from "buefy/dist/esm/radio";
import Field from "buefy/dist/esm/field";
import Modal from "buefy/dist/esm/modal";
import Autocomplete from "buefy/dist/esm/autocomplete";
import store from "./store";
import config from "./store/config";
import App from "./App.vue";
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages";
Vue.use(VueRouter);
Vue.use(Meta);
Vue.use(Switch);
Vue.use(Radio);
Vue.use(Field);
Vue.use(Modal);
Vue.use(Autocomplete);
const routes = [
{
path: "/",
component: Index,
name: "default",
},
{
path: "/container/:id",
component: Container,
name: "container",
props: true,
},
{
path: "/container/*",
component: ContainerNotFound,
name: "container-not-found",
},
{
path: "/settings",
component: Settings,
name: "settings",
},
{
path: "/show",
component: Show,
name: "show",
},
{
path: "/login",
component: Login,
name: "login",
},
{
path: "/*",
component: PageNotFound,
name: "page-not-found",
},
];
const router = new VueRouter({
mode: "history",
base: config.base + "/",
routes,
});
new Vue({
router,
store,
render: (h) => h(App),
}).$mount("#app");

67
assets/main.ts Normal file
View File

@@ -0,0 +1,67 @@
import "./styles.scss";
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { Autocomplete, Button, Dropdown, Switch, Radio, Field, Tooltip, Modal, Config } from "@oruga-ui/oruga-next";
import { bulmaConfig } from "@oruga-ui/theme-bulma";
import { createPinia } from "pinia";
import config from "./stores/config";
import App from "./App.vue";
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages";
const routes = [
{
path: "/",
component: Index,
name: "default",
},
{
path: "/container/:id",
component: Container,
name: "container",
props: true,
},
{
path: "/container/:pathMatch(.*)",
component: ContainerNotFound,
name: "container-not-found",
},
{
path: "/settings",
component: Settings,
name: "settings",
},
{
path: "/show",
component: Show,
name: "show",
},
{
path: "/login",
component: Login,
name: "login",
},
{
path: "/:pathMatch(.*)*",
component: PageNotFound,
name: "page-not-found",
},
];
const router = createRouter({
history: createWebHistory(`${config.base}/`),
routes,
});
createApp(App)
.use(router)
.use(createPinia())
.use(Autocomplete)
.use(Button)
.use(Dropdown)
.use(Switch)
.use(Tooltip)
.use(Modal)
.use(Radio)
.use(Field)
.use(Config, bulmaConfig)
.mount("#app");

View File

@@ -5,43 +5,33 @@
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Search from "../components/Search";
import LogContainer from "../components/LogContainer";
<script lang="ts" setup>
import { onMounted, toRefs, watchEffect } from "vue";
import Search from "@/components/Search.vue";
import LogContainer from "@/components/LogContainer.vue";
import { setTitle } from "@/composables/title";
import { useContainerStore } from "@/stores/container";
import { storeToRefs } from "pinia";
export default {
props: ["id"],
name: "Container",
components: {
LogContainer,
Search,
const store = useContainerStore();
const props = defineProps({
id: {
type: String,
required: true,
},
data() {
return {
title: "loading",
};
},
metaInfo() {
return {
title: this.title,
};
},
mounted() {
if (this.allContainersById[this.id]) {
this.title = this.allContainersById[this.id].name;
}
},
computed: {
...mapGetters(["allContainersById", "activeContainers"]),
},
watch: {
id() {
this.title = this.allContainersById[this.id].name;
},
allContainersById() {
this.title = this.allContainersById[this.id].name;
},
},
};
});
const { id } = toRefs(props);
const currentContainer = store.currentContainer(id);
const { activeContainers } = storeToRefs(store);
setTitle("loading");
onMounted(() => {
setTitle(currentContainer.value?.name);
});
watchEffect(() => setTitle(currentContainer.value?.name));
</script>

View File

@@ -11,13 +11,13 @@
</div>
</template>
<script>
<script lang="ts">
import { setTitle } from "@/composables/title";
export default {
name: "ContainerNotFound",
metaInfo() {
return {
title: "Not Found",
};
setup() {
setTitle("Container not found");
},
};
</script>

View File

@@ -50,7 +50,7 @@
@keyup.enter="onEnter()"
/>
<span class="icon is-left">
<icon name="search"></icon>
<search-icon />
</span>
</p>
</div>
@@ -76,60 +76,47 @@
</div>
</template>
<script>
import { mapState } from "vuex";
import Icon from "../components/Icon";
import PastTime from "../components/PastTime";
import config from "../store/config";
<script lang="ts" setup>
import { ref, computed } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { useContainerStore } from "@/stores/container";
import fuzzysort from "fuzzysort";
import SearchIcon from "~icons/mdi-light/magnify";
import PastTime from "../components/PastTime.vue";
import config from "@/stores/config";
export default {
name: "Index",
components: { Icon, PastTime },
data() {
return {
version: config.version,
search: null,
sort: "running",
secured: config.secured,
base: config.base,
};
},
methods: {
onEnter() {
if (this.results.length == 1) {
const [item] = this.results;
this.$router.push({ name: "container", params: { id: item.id, name: item.name } });
}
},
},
computed: {
...mapState(["containers"]),
mostRecentContainers() {
return [...this.containers].sort((a, b) => b.created - a.created);
},
runningContainers() {
return this.mostRecentContainers.filter((c) => c.state === "running");
},
allContainers() {
return this.containers;
},
results() {
if (this.search) {
return fuzzysort.go(this.search, this.allContainers, { key: "name" }).map((i) => i.obj);
}
switch (this.sort) {
case "all":
return this.mostRecentContainers;
case "running":
return this.runningContainers;
const { base, version, secured } = config;
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const router = useRouter();
default:
throw `Invalid sort order: ${this.sort}`;
}
},
},
};
const sort = ref("running");
const search = ref();
const results = computed(() => {
if (search.value) {
return fuzzysort.go(search.value, containers.value, { key: "name" }).map((i) => i.obj);
}
switch (sort.value) {
case "all":
return mostRecentContainers.value;
case "running":
return runningContainers.value;
default:
throw `Invalid sort order: ${sort.value}`;
}
});
const mostRecentContainers = computed(() => [...containers.value].sort((a, b) => b.created - a.created));
const runningContainers = computed(() => mostRecentContainers.value.filter((c) => c.state === "running"));
function onEnter() {
if (results.value.length == 1) {
const [item] = results.value;
router.push({ name: "container", params: { id: item.id, name: item.name } });
}
}
</script>
<style lang="scss" scoped>
.panel {

View File

@@ -49,8 +49,9 @@
</div>
</template>
<script>
import config from "../store/config";
<script lang="ts">
import config from "@/stores/config";
import { setTitle } from "@/composables/title";
export default {
name: "Login",
data() {
@@ -60,10 +61,8 @@ export default {
error: false,
};
},
metaInfo() {
return {
title: "Authentication Required",
};
setup() {
setTitle("Authentication Required");
},
methods: {
async onLogin() {

View File

@@ -3,21 +3,20 @@
<div class="hero-body">
<div class="container has-text-centered">
<h1 class="title">
Oops,
<small class="subtitle">this page doesn't exist</small>
404.
<small class="subtitle">This page does not exist.</small>
</h1>
</div>
</div>
</div>
</template>
<script>
<script lang="ts">
import { setTitle } from "@/composables/title";
export default {
name: "PageNotFound",
metaInfo() {
return {
title: "404 Error",
};
setup() {
setTitle("Page not found");
},
};
</script>

View File

@@ -10,9 +10,8 @@
>.
<span v-if="hasUpdate">
New version is available! Update to
<a :href="nextRelease.html_url" class="next-release" target="_blank" rel="noreferrer noopener">{{
nextRelease.name
}}</a
<a :href="nextRelease.html_url" class="next-release" target="_blank" rel="noreferrer noopener">
{{ nextRelease.name }}</a
>.
</span>
</div>
@@ -25,16 +24,22 @@
<div class="item">
<div class="columns is-vcentered">
<div class="column is-narrow">
<b-field>
<b-radio-button
v-model="hourStyle"
:native-value="value"
v-for="value in ['auto', '12', '24']"
:key="value"
>
<span class="is-capitalized">{{ value }}</span>
</b-radio-button>
</b-field>
<o-field>
<o-dropdown v-model="hourStyle" aria-role="list">
<template #trigger>
<o-button variant="primary" type="button">
<span class="is-capitalized">{{ hourStyle }}</span>
<span class="icon">
<carbon-caret-down />
</span>
</o-button>
</template>
<o-dropdown-item :value="value" aria-role="listitem" v-for="value in ['auto', '12', '24']" :key="value">
<span class="is-capitalized">{{ value }}</span>
</o-dropdown-item>
</o-dropdown>
</o-field>
</div>
<div class="column">
By default, Dozzle will use your browser's locale to format time. You can force to 12 or 24 hour style.
@@ -42,26 +47,37 @@
</div>
<div class="item">
<b-switch v-model="smallerScrollbars"> Use smaller scrollbars </b-switch>
<o-switch v-model="smallerScrollbars"> Use smaller scrollbars </o-switch>
</div>
<div class="item">
<b-switch v-model="showTimestamp"> Show timestamps </b-switch>
<o-switch v-model="showTimestamp"> Show timestamps </o-switch>
</div>
</div>
<div class="item">
<div class="columns is-vcentered">
<div class="column is-narrow">
<b-field>
<b-radio-button
v-model="size"
:native-value="value"
v-for="value in ['small', 'medium', 'large']"
:key="value"
>
<span class="is-capitalized">{{ value }}</span>
</b-radio-button>
</b-field>
<o-field>
<o-dropdown v-model="size" aria-role="list">
<template #trigger>
<o-button variant="primary" type="button">
<span class="is-capitalized">{{ size }}</span>
<span class="icon">
<carbon-caret-down />
</span>
</o-button>
</template>
<o-dropdown-item
:value="value"
aria-role="listitem"
v-for="value in ['small', 'medium', 'large']"
:key="value"
>
<span class="is-capitalized">{{ value }}</span>
</o-dropdown-item>
</o-dropdown>
</o-field>
</div>
<div class="column">Font size to use for logs</div>
</div>
@@ -73,78 +89,57 @@
</div>
<div class="item">
<b-switch v-model="search">
<o-switch v-model="search">
Enable searching with Dozzle using <code>command+f</code> or <code>ctrl+f</code>
</b-switch>
</o-switch>
</div>
<div class="item">
<b-switch v-model="showAllContainers"> Show stopped containers </b-switch>
<o-switch v-model="showAllContainers"> Show stopped containers </o-switch>
</div>
<div class="item">
<b-switch v-model="lightTheme"> Use light theme </b-switch>
<o-switch v-model="lightTheme"> Use light theme </o-switch>
</div>
</section>
</div>
</template>
<script>
<script lang="ts" setup>
import { ref } from "vue";
import gt from "semver/functions/gt";
import { mapActions, mapState } from "vuex";
import Icon from "../components/Icon";
import config from "../store/config";
import config from "@/stores/config";
import { setTitle } from "@/composables/title";
import {
search,
lightTheme,
smallerScrollbars,
showTimestamp,
hourStyle,
showAllContainers,
size,
} from "@/composables/settings";
export default {
props: [],
name: "Settings",
components: {
Icon,
},
data() {
return {
currentVersion: config.version,
nextRelease: null,
hasUpdate: false,
};
},
async created() {
const releases = await (await fetch("https://api.github.com/repos/amir20/dozzle/releases")).json();
if (this.currentVersion !== "master") {
this.hasUpdate = gt(releases[0].tag_name, this.currentVersion);
} else {
this.hasUpdate = true;
setTitle("Settings");
const currentVersion = config.version;
const nextRelease = ref({ html_url: "", name: "" });
const hasUpdate = ref(false);
async function fetchNextRelease() {
if (!["dev", "master"].includes(currentVersion)) {
const response = await fetch("https://api.github.com/repos/dozzle/dozzle/releases/latest");
if (response.ok) {
const releases = await response.json();
hasUpdate.value = gt(releases[0].tag_name, currentVersion);
nextRelease.value = releases[0];
}
this.nextRelease = releases[0];
},
metaInfo() {
return {
title: "Settings",
};
},
methods: {
...mapActions({
updateSetting: "UPDATE_SETTING",
}),
},
computed: {
...mapState(["settings"]),
...["search", "size", "smallerScrollbars", "showTimestamp", "showAllContainers", "lightTheme", "hourStyle"].reduce(
(map, name) => {
map[name] = {
get() {
return this.settings[name];
},
set(value) {
this.updateSetting({ [name]: value });
},
};
return map;
},
{}
),
},
};
} else {
hasUpdate.value = true;
}
}
fetchNextRelease();
</script>
<style lang="scss" scoped>
.title {

View File

@@ -1,29 +1,29 @@
<template></template>
<script lang="ts" setup>
import { useContainerStore } from "@/stores/container";
import { storeToRefs } from "pinia";
import { watch } from "vue";
import { useRoute, useRouter } from "vue-router";
<script>
import { mapActions, mapGetters, mapState } from "vuex";
export default {
props: [],
name: "Show",
computed: mapGetters(["visibleContainers"]),
watch: {
visibleContainers(newValue) {
if (newValue) {
if (this.$route.query.name) {
const [container, _] = this.visibleContainers.filter((c) => c.name == this.$route.query.name);
if (container) {
this.$router.push({ name: "container", params: { id: container.id } });
} else {
console.error(`No containers found matching name=${this.$route.query.name}. Redirecting to /`);
this.$router.push({ name: "default" });
}
} else {
console.error(`Expection query parameter name to be set. Redirecting to /`);
this.$router.push({ name: "default" });
}
const router = useRouter();
const route = useRoute();
const store = useContainerStore();
const { visibleContainers } = storeToRefs(store);
watch(visibleContainers, (newValue) => {
if (newValue) {
if (route.query.name) {
const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name);
if (container) {
router.push({ name: "container", params: { id: container.id } });
} else {
console.error(`No containers found matching name=${route.query.name}. Redirecting to /`);
router.push({ name: "default" });
}
},
},
};
} else {
console.error(`Expection query parameter name to be set. Redirecting to /`);
router.push({ name: "default" });
}
}
});
</script>
<style scoped></style>

6
assets/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/* eslint-disable */
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@@ -1,120 +0,0 @@
import Vue from "vue";
import Vuex from "vuex";
import storage from "store/dist/store.modern";
import { DEFAULT_SETTINGS, DOZZLE_SETTINGS_KEY } from "./settings";
import config from "./config";
Vue.use(Vuex);
const mql = window.matchMedia("(max-width: 770px)");
storage.set(DOZZLE_SETTINGS_KEY, { ...DEFAULT_SETTINGS, ...storage.get(DOZZLE_SETTINGS_KEY) });
const state = {
containers: [],
activeContainerIds: [],
searchFilter: null,
isMobile: mql.matches,
settings: storage.get(DOZZLE_SETTINGS_KEY),
authorizationNeeded: config.authorizationNeeded,
};
const mutations = {
SET_CONTAINERS(state, containers) {
const containersById = getters.allContainersById({ containers });
containers.forEach((container) => {
container.stat =
containersById[container.id] && containersById[container.id].stat
? containersById[container.id].stat
: { memoryUsage: 0, cpu: 0 };
});
state.containers = containers;
},
ADD_ACTIVE_CONTAINERS(state, { id }) {
state.activeContainerIds.push(id);
},
REMOVE_ACTIVE_CONTAINER(state, { id }) {
state.activeContainerIds.splice(state.activeContainerIds.indexOf(id), 1);
},
SET_SEARCH(state, filter) {
state.searchFilter = filter;
},
SET_MOBILE_WIDTH(state, value) {
state.isMobile = value;
},
UPDATE_SETTINGS(state, newValues) {
state.settings = { ...state.settings, ...newValues };
storage.set(DOZZLE_SETTINGS_KEY, state.settings);
},
UPDATE_CONTAINER(_, { container, data }) {
for (const [key, value] of Object.entries(data)) {
Vue.set(container, key, value);
}
},
};
const actions = {
APPEND_ACTIVE_CONTAINER({ commit }, container) {
commit("ADD_ACTIVE_CONTAINERS", container);
},
REMOVE_ACTIVE_CONTAINER({ commit }, container) {
commit("REMOVE_ACTIVE_CONTAINER", container);
},
SET_SEARCH({ commit }, filter) {
commit("SET_SEARCH", filter);
},
UPDATE_SETTING({ commit }, setting) {
commit("UPDATE_SETTINGS", setting);
},
UPDATE_STATS({ commit, getters: { allContainersById } }, stat) {
const container = allContainersById[stat.id];
if (container) {
commit("UPDATE_CONTAINER", { container, data: { stat } });
}
},
UPDATE_CONTAINER({ commit, getters: { allContainersById } }, event) {
switch (event.name) {
case "die":
const container = allContainersById[event.actorId];
commit("UPDATE_CONTAINER", { container, data: { state: "exited" } });
break;
default:
}
},
};
const getters = {
allContainersById({ containers }) {
return containers.reduce((map, obj) => {
map[obj.id] = obj;
return map;
}, {});
},
visibleContainers({ containers, settings: { showAllContainers } }) {
const filter = showAllContainers ? () => true : (c) => c.state === "running";
return containers.filter(filter);
},
activeContainers({ activeContainerIds }, { allContainersById }) {
return activeContainerIds.map((id) => allContainersById[id]);
},
};
if (!config.authorizationNeeded) {
const es = new EventSource(`${config.base}/api/events/stream`);
es.addEventListener("containers-changed", (e) => store.commit("SET_CONTAINERS", JSON.parse(e.data)), false);
es.addEventListener("container-stat", (e) => store.dispatch("UPDATE_STATS", JSON.parse(e.data)), false);
es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false);
}
mql.addEventListener("change", (e) => store.commit("SET_MOBILE_WIDTH", e.matches));
const store = new Vuex.Store({
state,
getters,
actions,
mutations,
});
export default store;

View File

@@ -1,11 +0,0 @@
export const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS";
export const DEFAULT_SETTINGS = {
search: true,
size: "medium",
menuWidth: 15,
smallerScrollbars: false,
showTimestamp: true,
showAllContainers: false,
lightTheme: false,
hourStyle: "auto",
};

View File

@@ -1,4 +1,6 @@
const config = JSON.parse(document.querySelector("script#config__json").textContent);
const text = document.querySelector("script#config__json")?.textContent || "{}";
const config = JSON.parse(text);
if (config.version == "{{ .Version }}") {
config.version = "master";
config.base = "";
@@ -9,5 +11,4 @@ if (config.version == "{{ .Version }}") {
config.authorizationNeeded = config.authorizationNeeded === "true";
config.secured = config.secured === "true";
}
export default config;

View File

@@ -0,0 +1,66 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { ref, Ref, computed } from "vue";
import { showAllContainers } from "@/composables/settings";
import config from "@/stores/config";
import type { Container, ContainerStat } from "@/types/Container";
export const useContainerStore = defineStore("container", () => {
const containers = ref<Container[]>([]);
const activeContainerIds = ref<string[]>([]);
const allContainersById = computed(() =>
containers.value.reduce((acc, container) => {
acc[container.id] = container;
return acc;
}, {} as Record<string, Container>)
);
const visibleContainers = computed(() => {
const filter = showAllContainers.value ? () => true : (c: Container) => c.state === "running";
return containers.value.filter(filter);
});
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
const es = new EventSource(`${config.base}/api/events/stream`);
es.addEventListener(
"containers-changed",
(e: Event) => (containers.value = JSON.parse((e as MessageEvent).data)),
false
);
es.addEventListener(
"container-stat",
(e) => {
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
const container = allContainersById.value[stat.id];
if (container) {
container.stat = stat;
}
},
false
);
// es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false);
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
const appendActiveContainer = ({ id }: Container) => activeContainerIds.value.push(id);
const removeActiveContainer = ({ id }: Container) =>
activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1);
return {
containers,
activeContainerIds,
allContainersById,
visibleContainers,
activeContainers,
currentContainer,
appendActiveContainer,
removeActiveContainer,
};
});
// @ts-ignore
if (import.meta.hot) {
// @ts-ignore
import.meta.hot.accept(acceptHMRUpdate(useContainerStore, import.meta.hot));
}

View File

@@ -1,6 +1,5 @@
@charset "utf-8";
@import "~bulma/sass/utilities/initial-variables.sass";
@import "bulma/sass/utilities/initial-variables.sass";
$body-background-color: var(--body-background-color);
@@ -25,13 +24,18 @@ $panel-heading-color: var(--panel-heading-color);
$link: $turquoise;
$link-active: $grey-dark;
@import "~bulma";
@import "../node_modules/splitpanes/dist/splitpanes.css";
@import "~buefy/src/scss/utils/_all";
@import "~buefy/src/scss/components/_switch";
@import "~buefy/src/scss/components/_radio";
@import "~buefy/src/scss/components/_modal";
@import "~buefy/src/scss/components/_autocomplete";
$dark-toolbar-color: rgba($black-bis, 0.7);
$light-toolbar-color: rgba($grey-darker, 0.7);
@import "bulma/bulma.sass";
@import "@oruga-ui/theme-bulma/dist/scss/components/utils/all.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/autocomplete.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/button.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/modal.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/switch.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/tooltip.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/dropdown.scss";
@import "splitpanes/dist/splitpanes.css";
html {
--scheme-main: #{$black};
@@ -46,6 +50,7 @@ html {
--secondary-color: #{$yellow};
--body-background-color: #{$black-bis};
--action-toolbar-background-color: #{$dark-toolbar-color};
--menu-item-active-background-color: var(--primary-color);
--menu-item-color: hsl(0, 6%, 87%);
@@ -72,6 +77,7 @@ html {
--secondary-color: #d8f0ca;
--body-background-color: #{$white-bis};
--action-toolbar-background-color: #{$light-toolbar-color};
--body-color: #{$grey-darker};
--menu-item-color: #{$grey-dark};
@@ -121,22 +127,6 @@ html.has-custom-scrollbars {
}
}
.is-settings-control {
background: rgba(0, 0, 0, 0.4);
color: #fff;
border-color: transparent;
&:hover {
border-color: var(--border-hover-color) !important;
background: rgba(0, 0, 0, 0.8) !important;
color: #fff !important;
}
&:focus {
box-shadow: none !important;
color: unset;
border-color: transparent;
}
}
@media screen and (min-width: 770px) {
.splitpanes__pane {
overflow: unset;
@@ -156,3 +146,7 @@ html.has-custom-scrollbars {
.modal {
z-index: 1000;
}
.button .button-wrapper > span {
display: contents;
}

16
assets/types/Container.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
export interface Container {
readonly id: string;
readonly created: number;
readonly image: string;
readonly name: string;
readonly state: "created" | "running" | "exited" | "dead" | "paused" | "restarting";
readonly status: string;
stat?: ContainerStat;
}
export interface ContainerStat {
readonly id: string;
readonly cpu: number;
readonly memory: number;
readonly memoryUsage: number;
}

6
assets/types/LogEntry.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
export interface LogEntry {
date: Date;
message: string;
key: string;
event?: string;
}

3
e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
videos
screenshots
__diff_output__

12
e2e/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM cypress/included:9.0.0
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
WORKDIR /e2e
COPY pnpm-lock.yaml ./
RUN pnpm fetch
COPY package.json ./
RUN pnpm install -r --offline

3
e2e/cypress.env.json Normal file
View File

@@ -0,0 +1,3 @@
{
"DOZZLE_DEFAULT": "http://localhost:3000/"
}

3
e2e/cypress.json Normal file
View File

@@ -0,0 +1,3 @@
{
"fixturesFolder": false
}

View File

@@ -0,0 +1,25 @@
/// <reference types="cypress" />
context("Dozzle default mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
beforeEach(() => {
cy.visit("/");
});
it("home screen", () => {
cy.get("li.running", { timeout: 10000 }).removeDates().matchImageSnapshot();
});
it("correct title", () => {
cy.title().should("eq", "1 containers - Dozzle");
cy.get("li.running:first a").click();
cy.title().should("include", "- Dozzle");
});
it("settings page", () => {
cy.get("a[href='/settings']").click();
cy.contains("About");
});
});

View File

@@ -0,0 +1,20 @@
/// <reference types="cypress" />
context("Dozzle settings mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
beforeEach(() => {
cy.visit("/version").clearLocalStorage().visit("/settings");
});
it("scrollbars", () => {
cy.contains("Use smaller scrollbars").click();
cy.get("html").should("have.class", "has-custom-scrollbars");
});
it("stopped containers", () => {
cy.contains("Show stopped containers")
.click()
.then(() => {
expect(JSON.parse(localStorage.getItem("DOZZLE_SETTINGS")).showAllContainers).to.be.true;
});
});
});

View File

@@ -0,0 +1,15 @@
/// <reference types="cypress" />
context("Dozzle light mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
before(() => {
cy.visit("/settings");
cy.contains("Use light theme").click();
});
beforeEach(() => {
cy.visit("/");
});
it("home screen", () => {
cy.get("li.running", { timeout: 10000 }).removeDates().matchImageSnapshot();
});
});

View File

@@ -0,0 +1,7 @@
/// <reference types="cypress" />
context("Dozzle routes", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
it("show", () => {
cy.visit("/show?name=dozzle").url().should("include", "/container/");
});
});

View File

@@ -0,0 +1,26 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const { addMatchImageSnapshotPlugin } = require("cypress-image-snapshot/plugin");
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
addMatchImageSnapshotPlugin(on, config);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,33 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import { addMatchImageSnapshotCommand } from "cypress-image-snapshot/command";
addMatchImageSnapshotCommand();
Cypress.Commands.add("removeDates", () => {
cy.window().then((win) => win.document.querySelectorAll("time").forEach((el) => el.remove()));
});

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -8,6 +8,8 @@ services:
- DOZZLE_FILTER=name=custom_base
- DOZZLE_BASE=/foobarbase
- DOZZLE_NO_ANALYTICS=1
ports:
- "8080:8080"
build:
context: ..
dozzle:
@@ -17,18 +19,20 @@ services:
environment:
- DOZZLE_FILTER=name=dozzle
- DOZZLE_NO_ANALYTICS=1
ports:
- "9090:8080"
build:
context: ..
integration:
cypress:
build:
context: .
command: yarn test
working_dir: /e2e
volumes:
- ./__tests__:/app/__tests__
- ./cypress:/e2e/cypress
- ./cypress.json:/e2e/cypress.json
environment:
- DEFAULT_URL=http://dozzle:8080/
- CUSTOM_URL=http://custom_base:8080/foobarbase
- DOZZLE_NO_ANALYTICS=1
- CYPRESS_DOZZLE_DEFAULT=http://dozzle:8080/
- CYPRESS_CUSTOM_DEFAULT=http://custom_base:8080/foobarbase
depends_on:
- dozzle
- custom_base

10
e2e/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "e2e",
"version": "1.0.0",
"scripts": {},
"license": "ISC",
"dependencies": {
"cypress": "^9.0.0",
"cypress-image-snapshot": "^4.0.1"
}
}

1447
e2e/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

14
go.mod
View File

@@ -1,12 +1,12 @@
module github.com/amir20/dozzle
require (
github.com/Microsoft/go-winio v0.5.0 // indirect
github.com/Microsoft/go-winio v0.5.1 // indirect
github.com/alexflint/go-arg v1.4.2
github.com/beme/abide v0.0.0-20190723115211-635a09831760
github.com/containerd/containerd v1.5.5 // indirect
github.com/containerd/containerd v1.5.7 // indirect
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/docker v20.10.10+incompatible
github.com/docker/docker v20.10.11+incompatible
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.0
@@ -23,10 +23,10 @@ require (
github.com/spf13/afero v1.6.0
github.com/stretchr/objx v0.3.0 // indirect
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.0.0-20210420072503-d25e30425868 // indirect
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 // indirect
google.golang.org/grpc v1.40.0 // indirect
golang.org/x/net v0.0.0-20211104170005-ce137452f963 // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 // indirect
google.golang.org/grpc v1.42.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

42
go.sum
View File

@@ -45,8 +45,8 @@ github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugX
github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU=
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/go-winio v0.5.1 h1:aPJp2QD7OOrhO5tQXqQoGSJc+DjDtWTGLOmNyAm6FgY=
github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
@@ -54,7 +54,7 @@ github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg3
github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg=
github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00=
github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600=
github.com/Microsoft/hcsshim v0.8.18/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4=
github.com/Microsoft/hcsshim v0.8.21/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4=
github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU=
github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
@@ -109,7 +109,11 @@ github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJ
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE=
github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU=
@@ -143,8 +147,8 @@ github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo
github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI=
github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s=
github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g=
github.com/containerd/containerd v1.5.5 h1:q1gxsZsGZ8ddVe98yO6pR21b5xQSMiR61lD0W96pgQo=
github.com/containerd/containerd v1.5.5/go.mod h1:oSTh0QpT1w6jYcGmbiSbxv9OSQYaa88mPyWIuU79zyo=
github.com/containerd/containerd v1.5.7 h1:rQyoYtj4KddB3bxG6SAqd4+08gePNyJjRqvOIfV3rkM=
github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c=
github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
@@ -230,8 +234,8 @@ github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TT
github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v20.10.10+incompatible h1:GKkP0T7U4ks6X3lmmHKC2QDprnpRJor2Z5a8m62R9ZM=
github.com/docker/docker v20.10.10+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v20.10.11+incompatible h1:OqzI/g/W54LczvhnccGqniFoQghHx3pklbLuhfXpqGo=
github.com/docker/docker v20.10.11+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
@@ -254,6 +258,7 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
@@ -385,6 +390,7 @@ github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJ
github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
@@ -482,7 +488,7 @@ github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59P
github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0=
github.com/opencontainers/runc v1.0.1/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
github.com/opencontainers/runc v1.0.2/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
@@ -613,7 +619,6 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
@@ -667,7 +672,6 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@@ -676,7 +680,6 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -713,8 +716,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210420072503-d25e30425868 h1:mHVdVrNGft0Bv5N0WIf3/ujpDOQOe6KxvwlIikPbMr0=
golang.org/x/net v0.0.0-20210420072503-d25e30425868/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.0.0-20211104170005-ce137452f963 h1:8gJUadZl+kWvZBqG/LautX0X6qe5qTC2VI/3V3NBRAY=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -729,7 +732,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -791,9 +793,11 @@ golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -847,7 +851,6 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -893,8 +896,8 @@ google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfG
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 h1:3V2dxSZpz4zozWWUq36vUxXEKnSYitEH2LdsAx+RUmg=
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 h1:ZONpjmFT5e+I/0/xE3XXbG5OIvX2hRYzol04MhKBl2E=
google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -911,8 +914,9 @@ google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.40.0 h1:AGJ0Ih4mHjSeibYkFGh1dD9KJ/eOtZ93I6hoHhukQ5Q=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
node_modules

View File

@@ -1 +0,0 @@
__diff_output__

View File

@@ -1,8 +0,0 @@
FROM amir20/docker-alpine-puppeteer:v1
COPY package*.json yarn.lock /app/
RUN yarn
COPY . /app/
CMD ["yarn", "test"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,22 +0,0 @@
const { removeTimes } = require("../utils");
const { CUSTOM_URL: URL } = process.env;
describe("Dozzle with custom base", () => {
beforeEach(async () => {
await page.goto(URL, { waitUntil: "networkidle2" });
});
it("renders full page on desktop", async () => {
await removeTimes(page);
const image = await page.screenshot({ fullPage: true });
expect(image).toMatchImageSnapshot();
});
it("and shows one container with correct title", async () => {
await removeTimes(page);
const menuTitle = await page.$eval("aside ul.menu-list li a", (e) => e.title);
expect(menuTitle).toEqual("custom_base");
});
});

View File

@@ -1,76 +0,0 @@
const puppeteer = require("puppeteer");
const { removeTimes } = require("../utils");
const iPhoneX = puppeteer.devices["iPhone X"];
const iPadLandscape = puppeteer.devices["iPad landscape"];
const { DEFAULT_URL: URL } = process.env;
describe("home page", () => {
beforeEach(async () => {
await page.goto(URL, { waitUntil: "networkidle2" });
});
it("renders full page on desktop", async () => {
await removeTimes(page);
const image = await page.screenshot({ fullPage: true });
expect(image).toMatchImageSnapshot();
});
it("renders ipad viewport", async () => {
await page.emulate(iPadLandscape);
await removeTimes(page);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
it("renders iphone viewport", async () => {
await page.emulate(iPhoneX);
await removeTimes(page);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
it("displays iphone menu", async () => {
await page.emulate(iPhoneX);
await page.click("a.navbar-burger");
const menuText = await page.$eval("aside ul.menu-list.is-hidden-mobile li a", (e) => e.textContent);
expect(menuText.trim()).toEqual("dozzle");
});
describe("has menu visible", () => {
beforeAll(async () => {
await jestPuppeteer.resetBrowser();
});
beforeEach(async () => {
await page.goto(URL, { waitUntil: "networkidle2" });
});
it("and shows one container with correct title", async () => {
const menuTitle = await page.$eval("aside ul.menu-list li a", (e) => e.title);
expect(menuTitle).toEqual("dozzle");
});
it("and menu is clickable", async () => {
await page.click("aside ul.menu-list li a");
const className = await page.$eval("aside ul.menu-list li a", (e) => e.className);
expect(className).toContain("router-link-exact-active");
});
it("and when clicked shows logs", async () => {
await page.click("aside ul.menu-list li a");
await page.waitForSelector("ul.events li span.text");
const text = await page.$eval("ul.events li:nth-child(1) span.text", (e) => e.textContent);
expect(text).toContain("Dozzle version dev");
});
});
});

View File

@@ -1,41 +0,0 @@
const puppeteer = require("puppeteer");
const { removeTimes } = require("../utils");
const iPhoneX = puppeteer.devices["iPhone X"];
const iPadLandscape = puppeteer.devices["iPad landscape"];
const { DEFAULT_URL: URL } = process.env;
describe("Dozzle with light mode", () => {
beforeAll(async () => {
await page.goto(URL + "/settings", { waitUntil: "networkidle2" });
await page.$$eval("label.switch", (elements) => {
elements.filter((e) => e.textContent.trim() === "Use light theme")[0].click();
});
});
beforeEach(async () => {
await page.goto(URL, { waitUntil: "networkidle2" });
});
it("renders full page on desktop", async () => {
await removeTimes(page);
const image = await page.screenshot({ fullPage: true });
expect(image).toMatchImageSnapshot();
});
it("renders ipad viewport", async () => {
await page.emulate(iPadLandscape);
await removeTimes(page);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
it("renders iphone viewport", async () => {
await page.emulate(iPhoneX);
await removeTimes(page);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
});

View File

@@ -1,9 +0,0 @@
module.exports = {
launch: {
headless: process.env.HEADLESS !== "false",
defaultViewport: { width: 1920, height: 1200 },
args: ["--no-sandbox", "--disable-setuid-sandbox"],
executablePath: process.env.CHROME_EXE_PATH || "",
},
browserContext: "incognito",
};

View File

@@ -1,5 +0,0 @@
const { toMatchImageSnapshot } = require("jest-image-snapshot");
expect.extend({ toMatchImageSnapshot });
jest.setTimeout(5000);

View File

@@ -1,24 +0,0 @@
{
"name": "test",
"version": "1.0.0",
"description": "",
"scripts": {
"test": "jest"
},
"author": "",
"license": "ISC",
"dependencies": {
"jest": "^27.0.6",
"jest-image-snapshot": "^4.0.0",
"puppeteer": "^10.4.0"
},
"jest": {
"preset": "jest-puppeteer",
"setupFilesAfterEnv": [
"<rootDir>/jest-setup.js"
]
},
"devDependencies": {
"jest-puppeteer": "^6.0.0"
}
}

View File

@@ -1,8 +0,0 @@
async function removeTimes(page) {
await page.waitForSelector("time");
await page.evaluate(() => {
(document.querySelectorAll("time") || []).forEach((el) => el.remove());
});
}
module.exports = { removeTimes };

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
module.exports = {
clearMocks: true,
testEnvironment: "jsdom",
moduleFileExtensions: ["js", "json", "vue"],
coveragePathIgnorePatterns: ["node_modules"],
testPathIgnorePatterns: ["node_modules", "<rootDir>/integration/"],
transformIgnorePatterns: ["node_modules"],
watchPathIgnorePatterns: ["<rootDir>/node_modules/"],
snapshotSerializers: ["jest-serializer-vue"],
transform: {
".*\\.vue$": "vue-jest",
"^.+\\.js$": "babel-jest",
},
};

15
jest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Config } from "@jest/types";
const config: Config.InitialOptions = {
preset: "ts-jest",
testEnvironment: "jsdom",
testPathIgnorePatterns: ["node_modules", "<rootDir>/integration/", "<rootDir>/e2e/"],
transform: {
"^.+\\.vue$": "@vue/vue3-jest",
},
moduleNameMapper: {
"@/(.*)": ["<rootDir>/assets/$1"],
},
};
export default config;

View File

@@ -1,3 +0,0 @@
{
"include": ["./assets/**/*"]
}

12
main.go
View File

@@ -41,7 +41,7 @@ func (args) Version() string {
return version
}
//go:embed static
//go:embed dist
var content embed.FS
func main() {
@@ -95,17 +95,17 @@ func main() {
Password: args.Password,
}
static, err := fs.Sub(content, "static")
assets, err := fs.Sub(content, "dist")
if err != nil {
log.Fatalf("Could not open embedded static folder: %v", err)
log.Fatalf("Could not open embedded dist folder: %v", err)
}
if _, ok := os.LookupEnv("LIVE_FS"); ok {
log.Info("Using live filesystem at ./static")
static = os.DirFS("./static")
log.Info("Using live filesystem at ./dist")
assets = os.DirFS("./dist")
}
srv := web.CreateServer(dockerClient, static, config)
srv := web.CreateServer(dockerClient, assets, config)
go doStartEvent(args)
go func() {
log.Infof("Accepting connections on %s", srv.Addr)

View File

@@ -1,94 +1,89 @@
{
"name": "dozzle",
"version": "3.8.4",
"description": "Realtime log viewer for docker containers. ",
"scripts": {
"watch": "npm-run-all -p watch:*",
"watch:assets": "webpack --mode=development --watch",
"watch:server": "LIVE_FS=true reflex -c .reflex",
"dev": "make fake_static && npm-run-all -p dev-server watch:server",
"dev-server": "webpack serve --mode=development",
"build": "rm -rf static && webpack --mode=production --progress",
"clean": "rm -rf static",
"release": "release-it",
"test": "TZ=UTC jest",
"postinstall": "husky install"
},
"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": {
"ansi-to-html": "^0.7.2",
"buefy": "^0.9.10",
"bulma": "^0.9.3",
"date-fns": "^2.25.0",
"dompurify": "^2.3.3",
"fuzzysort": "^1.1.4",
"hotkeys-js": "^3.8.7",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"semver": "^7.3.5",
"splitpanes": "^2.3.8",
"store": "^2.0.12",
"vue": "^2.6.14",
"vue-meta": "^2.4.0",
"vue-router": "^3.5.3",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.15.8",
"@babel/plugin-transform-runtime": "^7.15.8",
"@vue/component-compiler-utils": "^3.3.0",
"@vue/test-utils": "^1.2.2",
"autoprefixer": "^10.4.0",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^27.3.1",
"babel-preset-env": "^1.7.0",
"caniuse-lite": "^1.0.30001272",
"css-loader": "^6.5.0",
"eventsourcemock": "^2.0.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"jest": "^27.3.1",
"jest-serializer-vue": "^2.0.2",
"lint-staged": "^11.2.6",
"mini-css-extract-plugin": "^2.4.3",
"npm-run-all": "^4.1.5",
"postcss": "^8.3.11",
"postcss-loader": "^6.2.0",
"prettier": "^2.4.1",
"release-it": "^14.11.6",
"sass": "^1.43.4",
"sass-loader": "^12.3.0",
"vue-hot-reload-api": "^2.3.4",
"vue-jest": "^3.0.7",
"vue-loader": "^15.9.8",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.6.14",
"webpack": "^5.60.0",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.4.0",
"webpack-pwa-manifest": "^4.3.0"
},
"lint-staged": {
"*.{js,vue,css}": [
"prettier --write"
]
},
"release-it": {
"github": {
"release": false,
"releaseNotes": "git log --pretty=format:\"* %s (%h)\" $(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1))...HEAD~1"
"name": "dozzle",
"version": "3.10.0",
"description": "Realtime log viewer for docker containers. ",
"homepage": "https://github.com/amir20/dozzle#readme",
"bugs": {
"url": "https://github.com/amir20/dozzle/issues"
},
"npm": {
"publish": false
"repository": {
"type": "git",
"url": "git+https://github.com/amir20/dozzle.git"
},
"license": "ISC",
"author": "Amir Raminfar <findamir@gmail.com>",
"scripts": {
"watch:assets": "vite --open",
"watch:server": "LIVE_FS=true DOZZLE_ADDR=:3100 reflex -c .reflex",
"dev": "make fake_assets && npm-run-all -p watch:assets watch:server",
"build": "vite build",
"release": "release-it",
"test": "TZ=UTC jest",
"postinstall": "husky install"
},
"dependencies": {
"@iconify-json/carbon": "^1.0.11",
"@iconify-json/cil": "^1.0.1",
"@iconify-json/mdi": "^1.0.11",
"@iconify-json/mdi-light": "^1.0.1",
"@iconify-json/octicon": "^1.0.5",
"@oruga-ui/oruga-next": "^0.4.8",
"@oruga-ui/theme-bulma": "^0.1.5",
"@vitejs/plugin-vue": "^1.10.1",
"@vue/compiler-sfc": "^3.2.23",
"@vueuse/core": "^7.1.2",
"ansi-to-html": "^0.7.2",
"bulma": "^0.9.3",
"date-fns": "^2.26.0",
"fuzzysort": "^1.1.4",
"hotkeys-js": "^3.8.7",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"pinia": "^2.0.4",
"sass": "^1.43.5",
"semver": "^7.3.5",
"splitpanes": "^3.0.6",
"typescript": "^4.5.2",
"unplugin-auto-import": "^0.5.1",
"unplugin-icons": "^0.12.20",
"unplugin-vue-components": "^0.17.2",
"vite": "^2.6.14",
"vue": "^3.2.22",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.16.4",
"@babel/preset-env": "^7.16.4",
"@pinia/testing": "^0.0.6",
"@types/jest": "^27.0.3",
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.throttle": "^4.1.6",
"@types/semver": "^7.3.9",
"@vue/test-utils": "^2.0.0-rc.16",
"@vue/vue3-jest": "^27.0.0-alpha.4",
"eventsourcemock": "^2.0.0",
"husky": "^7.0.4",
"jest": "^27.4.0",
"jest-serializer-vue": "^2.0.2",
"lint-staged": "^12.1.2",
"npm-run-all": "^4.1.5",
"prettier": "^2.5.0",
"release-it": "^14.11.8",
"ts-jest": "^27.0.7",
"ts-node": "^10.4.0"
},
"lint-staged": {
"*.{js,vue,css}": [
"prettier --write"
]
},
"release-it": {
"github": {
"release": false,
"releaseNotes": "git log --pretty=format:\"* %s (%h)\" $(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1))...HEAD~1"
},
"npm": {
"publish": false
}
}
}
}

6350
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More