1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 21:33:18 +01:00
* 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
This commit is contained in:
Amir Raminfar
2021-11-16 10:55:44 -08:00
committed by GitHub
parent 215ea12e80
commit 412a10256d
92 changed files with 4294 additions and 8064 deletions

View File

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

View File

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

View File

@@ -39,9 +39,9 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2.4.0 uses: actions/checkout@v2.4.0
- name: Build images - 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 - 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: buildx:
needs: [go-test, npm-test, int-test] needs: [go-test, npm-test, int-test]
name: Release name: Release

View File

@@ -42,6 +42,6 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2.4.0 uses: actions/checkout@v2.4.0
- name: Build images - 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 - 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

@@ -10,7 +10,7 @@ COPY pnpm-lock.yaml ./
RUN pnpm fetch --prod RUN pnpm fetch --prod
# Copy files # Copy files
COPY package.json .* webpack*.js ./ COPY package.json .* vite.config.ts index.html ./
# Copy assets to build # Copy assets to build
COPY assets ./assets COPY assets ./assets
@@ -32,10 +32,13 @@ COPY go.* ./
RUN go mod download RUN go mod download
# Copy assets built with node # Copy assets built with node
COPY --from=node /build/static ./static COPY --from=node /build/dist ./dist
# Copy all other files # Copy all other files
COPY . . COPY analytics ./analytics
COPY docker ./docker
COPY web ./web
COPY main.go ./
# Args # Args
ARG TAG=dev ARG TAG=dev

View File

@@ -1,24 +1,24 @@
.PHONY: clean .PHONY: clean
clean: clean:
@rm -rf static @rm -rf dist
@go clean -i @go clean -i
.PHONY: static .PHONY: dist
static: dist:
@pnpm build @pnpm build
.PHONY: fake_static .PHONY: fake_assets
fake_static: fake_assets:
@echo 'Skipping asset build' @echo 'Skipping asset build'
@mkdir -p static @mkdir -p dist
@echo "assets build was skipped" > static/index.html @echo "assets build was skipped" > dist/index.html
.PHONY: test .PHONY: test
test: fake_static test: fake_assets
go test -cover ./... go test -cover ./...
.PHONY: build .PHONY: build
build: static build: dist
CGO_ENABLED=0 go build -ldflags "-s -w" CGO_ENABLED=0 go build -ldflags "-s -w"
.PHONY: docker .PHONY: docker
@@ -31,4 +31,4 @@ dev:
.PHONY: int .PHONY: int
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

@@ -1,64 +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: "" }));
jest.mock("~icons/octicon/download-24", () => {}, { virtual: true });
jest.mock("~icons/octicon/trash-24", () => {}, { virtual: true });
jest.mock("~icons/mdi-light/chevron-double-down", () => {}, { virtual: true });
jest.mock("~icons/mdi-light/chevron-left", () => {}, { virtual: true });
jest.mock("~icons/mdi-light/chevron-right", () => {}, { virtual: true });
jest.mock("~icons/mdi-light/magnify", () => {}, { virtual: true });
jest.mock("~icons/cil/columns", () => {}, { virtual: true });
jest.mock("~icons/octicon/container-24", () => {}, { virtual: true });
jest.mock("~icons/mdi-light/cog", () => {}, { virtual: true });
const localVue = createLocalVue();
localVue.use(Vuex);
describe("<App />", () => {
const stubs = { RouterLink: RouterLinkStub, "router-view": true, "chevron-left-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> <mobile-menu v-if="isMobile && !authorizationNeeded"></mobile-menu>
<splitpanes @resized="onResized($event)"> <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> <side-menu @search="showFuzzySearch"></side-menu>
</pane> </pane>
<pane min-size="10"> <pane min-size="10">
@@ -18,7 +18,7 @@
show-title show-title
scrollable scrollable
closable closable
@close="removeActiveContainer(other)" @close="store.dispatch('REMOVE_ACTIVE_CONTAINER', other)"
></log-container> ></log-container>
</pane> </pane>
</template> </template>
@@ -27,128 +27,96 @@
</splitpanes> </splitpanes>
<button <button
@click="collapseNav = !collapseNav" @click="collapseNav = !collapseNav"
class="button is-small is-rounded is-settings-control" class="button is-rounded is-settings-control"
:class="{ collapsed: collapseNav }" :class="{ collapsed: collapseNav }"
id="hide-nav" id="hide-nav"
v-if="!isMobile && !authorizationNeeded" v-if="!isMobile && !authorizationNeeded"
> >
<span class="icon ml-2" v-if="collapseNav"> <span class="icon ml-2" v-if="collapseNav">
<chevron-right-icon /> <mdi-light-chevron-right />
</span> </span>
<span class="icon" v-else> <span class="icon" v-else>
<chevron-left-icon /> <mdi-light-chevron-left />
</span> </span>
</button> </button>
</main> </main>
</template> </template>
<script> <script lang="ts" setup>
import { mapActions, mapGetters, mapState } from "vuex";
import { Splitpanes, Pane } from "splitpanes"; import { Splitpanes, Pane } from "splitpanes";
import { ref, onMounted, watchEffect, toRefs, computed, watch } from "vue";
import { useStore } from "vuex";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import hotkeys from "hotkeys-js"; import hotkeys from "hotkeys-js";
import { setTitle } from "./composables/title";
import { isMobile } from "./composables/mediaQuery";
import LogContainer from "./components/LogContainer"; import FuzzySearchModal from "./components/FuzzySearchModal.vue";
import SideMenu from "./components/SideMenu"; import LogContainer from "./components/LogContainer.vue";
import MobileMenu from "./components/MobileMenu"; import SideMenu from "./components/SideMenu.vue";
import MobileMenu from "./components/MobileMenu.vue";
import PastTime from "./components/PastTime"; const collapseNav = ref(false);
import FuzzySearchModal from "./components/FuzzySearchModal"; const { oruga } = useProgrammatic();
const store = useStore();
const { menuWidth } = toRefs(store.state.settings);
const { containers, authorizationNeeded } = toRefs(store.state);
const activeContainers = computed(() => store.getters.activeContainers);
const visibleContainers = computed(() => store.getters.visibleContainers);
const lightTheme = computed(() => store.state.settings.lightTheme);
const smallerScrollbars = computed(() => store.state.settings.smallerScrollbars);
import ChevronLeftIcon from "~icons/mdi-light/chevron-left"; onMounted(() => {
import ChevronRightIcon from "~icons/mdi-light/chevron-right"; if (smallerScrollbars.value) {
export default {
name: "App",
components: {
SideMenu,
LogContainer,
MobileMenu,
Splitpanes,
PastTime,
Pane,
ChevronLeftIcon,
ChevronRightIcon,
},
data() {
return {
title: "",
collapseNav: false,
};
},
metaInfo() {
return {
title: this.title,
titleTemplate: "%s - Dozzle",
};
},
mounted() {
if (this.hasSmallerScrollbars) {
document.documentElement.classList.add("has-custom-scrollbars"); document.documentElement.classList.add("has-custom-scrollbars");
} }
if (this.hasLightTheme) { if (lightTheme.value) {
document.documentElement.setAttribute("data-theme", "light"); document.documentElement.setAttribute("data-theme", "light");
} }
this.menuWidth = this.settings.menuWidth;
hotkeys("command+k, ctrl+k", (event, handler) => { hotkeys("command+k, ctrl+k", (event, handler) => {
event.preventDefault(); event.preventDefault();
this.showFuzzySearch(); showFuzzySearch();
}); });
}, });
watch: {
hasSmallerScrollbars(newValue, oldValue) { watchEffect(() => {
if (newValue) { setTitle(`${visibleContainers.value.length} containers`);
});
watchEffect(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars"); document.documentElement.classList.add("has-custom-scrollbars");
} else { } else {
document.documentElement.classList.remove("has-custom-scrollbars"); document.documentElement.classList.remove("has-custom-scrollbars");
} }
},
hasLightTheme(newValue, oldValue) { if (lightTheme.value) {
if (newValue) {
document.documentElement.setAttribute("data-theme", "light"); document.documentElement.setAttribute("data-theme", "light");
} else { } else {
document.documentElement.removeAttribute("data-theme"); document.documentElement.removeAttribute("data-theme");
} }
}, });
visibleContainers() {
this.title = `${this.visibleContainers.length} containers`; function showFuzzySearch() {
}, oruga.modal.open({
}, // parent: this,
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, component: FuzzySearchModal,
animation: "false", animation: "false",
width: 600, width: 600,
active: true,
}); });
}, }
}, function onResized(e) {
}; if (e.length == 2) {
const menuWidth = e[0].size;
store.dispatch("UPDATE_SETTING", { menuWidth });
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
::v-deep .splitpanes--vertical > .splitpanes__splitter { :deep(.splitpanes--vertical > .splitpanes__splitter) {
min-width: 3px; min-width: 3px;
background: var(--border-color); background: var(--border-color);
&:hover { &:hover {

View File

@@ -1,50 +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"
>
<chevron-left-icon-stub />
</span>
</button>
</main>
`;

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

@@ -0,0 +1,36 @@
// 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']
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,38 @@
{{ state }} {{ state }}
</div> </div>
<div class="column is-narrow" v-if="stat.memoryUsage !== null"> <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"> <span class="has-text-weight-bold">
{{ formatBytes(stat.memoryUsage) }} {{ formatBytes(stat.memoryUsage) }}
</span> </span>
</div> </div>
<div class="column is-narrow" v-if="stat.cpu !== null"> <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> <span class="has-text-weight-bold"> {{ stat.cpu }}% </span>
</div> </div>
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
export default { defineProps({
props: {
stat: Object, stat: Object,
state: String, state: String,
}, });
name: "ContainerStat", function formatBytes(bytes: number, decimals = 2) {
methods: {
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}, }
},
};
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.has-spacer {
&::after {
content: " ";
}
}
</style>

View File

@@ -7,13 +7,10 @@
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
export default { defineProps({
props: {
container: Object, container: Object,
}, });
name: "ContainerTitle",
};
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="panel"> <div class="panel">
<b-autocomplete <o-autocomplete
ref="autocomplete" ref="autocomplete"
v-model="query" v-model="query"
placeholder="Search containers using ⌘ + k, ⌃k" placeholder="Search containers using ⌘ + k or ctrl + k"
field="name" field="name"
open-on-focus open-on-focus
keep-first keep-first
@@ -11,11 +11,11 @@
:data="results" :data="results"
@select="selected" @select="selected"
> >
<template slot-scope="props"> <template v-slot="props">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<span class="icon is-small" :class="props.option.state"> <span class="icon is-small" :class="props.option.state">
<container-icon /> <octicon-container-24 />
</span> </span>
</div> </div>
<div class="media-content"> <div class="media-content">
@@ -23,23 +23,19 @@
</div> </div>
<div class="media-right"> <div class="media-right">
<span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column"> <span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column">
<columns-icon /> <cil-columns />
</span> </span>
</div> </div>
</div> </div>
</template> </template>
</b-autocomplete> </o-autocomplete>
</div> </div>
</template> </template>
<script> <script lang="ts">
import { mapState, mapActions } from "vuex"; import { mapState, mapActions } from "vuex";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import PastTime from "./PastTime";
import ContainerIcon from "~icons/octicon/container-24";
import ColumnsIcon from "~icons/cil/columns";
export default { export default {
props: { props: {
maxResults: { maxResults: {
@@ -53,11 +49,7 @@ export default {
}; };
}, },
name: "FuzzySearchModal", name: "FuzzySearchModal",
components: {
PastTime,
ContainerIcon,
ColumnsIcon,
},
mounted() { mounted() {
this.$nextTick(() => this.$refs.autocomplete.focus()); this.$nextTick(() => this.$refs.autocomplete.focus());
}, },
@@ -110,6 +102,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.panel { .panel {
min-height: 400px; min-height: 400px;
width: 580px;
} }
.running { .running {
@@ -126,7 +119,7 @@ export default {
} }
} }
::v-deep a.dropdown-item { :deep(a.dropdown-item) {
padding-right: 1em; padding-right: 1em;
.media-right { .media-right {
visibility: hidden; visibility: hidden;

View File

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

View File

@@ -1,34 +1,32 @@
<template> <template>
<div class="toolbar mr-0 is-vcentered is-hidden-mobile"> <div class="mr-0 toolbar is-vcentered is-hidden-mobile">
<div class="is-flex"> <div class="is-flex">
<b-tooltip type="is-dark" label="Clear"> <o-tooltip type="is-dark" label="Clear">
<a @click="onClearClicked" class="button is-small is-light is-inverted pl-1 pr-1" id="clear"> <a @click="onClearClicked" class="pl-1 pr-1 button is-small is-light is-inverted" id="clear">
<clear-icon /> <octicon-trash-24 />
</a> </a>
</b-tooltip> </o-tooltip>
<div class="is-flex-grow-1"></div> <div class="is-flex-grow-1"></div>
<b-tooltip type="is-dark" label="Download"> <o-tooltip type="is-dark" label="Download">
<a <a
class="button is-small is-light is-inverted pl-1 pr-1" class="pl-1 pr-1 button is-small is-light is-inverted"
id="download" id="download"
:href="`${base}/api/logs/download?id=${container.id}`" :href="`${base}/api/logs/download?id=${container.id}`"
download download
> >
<download-icon /> <octicon-download-24 />
</a> </a>
</b-tooltip> </o-tooltip>
</div> </div>
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
import config from "../store/config"; import config from "../store/config";
import hotkeys from "hotkeys-js"; import hotkeys from "hotkeys-js";
import DownloadIcon from "~icons/octicon/download-24"; import { onMounted, onUnmounted } from "vue";
import ClearIcon from "~icons/octicon/trash-24";
export default { const props = defineProps({
props: {
onClearClicked: { onClearClicked: {
type: Function, type: Function,
default: () => {}, default: () => {},
@@ -36,27 +34,20 @@ export default {
container: { container: {
type: Object, type: Object,
}, },
},
name: "LogActionsToolbar",
components: {
DownloadIcon,
ClearIcon,
},
computed: {
base() {
return config.base;
},
},
mounted() {
hotkeys("shift+command+l, shift+ctrl+l", (event, handler) => {
this.onClearClicked();
event.preventDefault();
}); });
},
const { base } = config;
const onHotkey = (event: Event) => {
props.onClearClicked();
event.preventDefault();
}; };
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
</script> </script>
<style> <style lang="scss" scoped>
#download.button, #download.button,
#clear.button { #clear.button {
.icon { .icon {

View File

@@ -8,31 +8,40 @@
<div class="column is-narrow is-paddingless"> <div class="column is-narrow is-paddingless">
<container-stat :stat="container.stat" :state="container.state"></container-stat> <container-stat :stat="container.stat" :state="container.state"></container-stat>
</div> </div>
<div class="column is-narrow is-paddingless" v-if="closable"> <div class="mr-2 column is-narrow is-paddingless" v-if="closable">
<button class="delete is-medium" @click="$emit('close')"></button> <button class="delete is-medium" @click="$emit('close')"></button>
</div> </div>
<!-- <div class="mr-2 column is-narrow is-paddingless">
<o-dropdown aria-role="list" position="bottom-left">
<template v-slot:trigger>
<span class="btn">
<span class="icon">
<carbon-verflow-menu-vertical />
</span>
</span>
</template>
<o-dropdown-item aria-role="listitem"> Clear </o-dropdown-item>
<o-dropdown-item aria-role="listitem">Download</o-dropdown-item>
</o-dropdown>
</div> -->
</div> </div>
<log-actions-toolbar :container="container" :onClearClicked="onClearClicked"></log-actions-toolbar> <log-actions-toolbar :container="container" :onClearClicked="onClearClicked"></log-actions-toolbar>
</template> </template>
<template v-slot="{ setLoading }"> <template v-slot="{ setLoading }">
<log-viewer-with-source ref="logViewer" :id="id" @loading-more="setLoading($event)"></log-viewer-with-source> <log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)"></log-viewer-with-source>
</template> </template>
</scrollable-view> </scrollable-view>
</template> </template>
<script> <script lang="ts" setup>
import LogViewerWithSource from "./LogViewerWithSource"; import { ref, toRefs } from "vue";
import LogActionsToolbar from "./LogActionsToolbar"; import useContainer from "../composables/container";
import ScrollableView from "./ScrollableView";
import ContainerTitle from "./ContainerTitle";
import ContainerStat from "./ContainerStat";
import containerMixin from "./mixins/container";
export default { const props = defineProps({
mixins: [containerMixin],
props: {
id: { id: {
type: String, type: String,
required: true,
}, },
showTitle: { showTitle: {
type: Boolean, type: Boolean,
@@ -46,21 +55,15 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, });
name: "LogContainer", const { id } = toRefs(props);
components: { const { container } = useContainer(id);
LogViewerWithSource,
LogActionsToolbar, const viewer = ref<HTMLElement>();
ScrollableView,
ContainerTitle, function onClearClicked() {
ContainerStat, viewer.value?.clear();
}, }
methods: {
onClearClicked() {
this.$refs.logViewer.clear();
},
},
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
button.delete { button.delete {

View File

@@ -1,10 +1,11 @@
import { mount } from "@vue/test-utils";
import { createStore } from "vuex";
// @ts-ignore
import EventSource, { sources } from "eventsourcemock";
import debounce from "lodash.debounce"; 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 LogEventSource from "./LogEventSource.vue";
import LogViewer from "./LogViewer.vue"; import LogViewer from "./LogViewer.vue";
import { mocked } from "ts-jest/utils";
jest.mock("lodash.debounce", () => jest.mock("lodash.debounce", () =>
jest.fn((fn) => { jest.fn((fn) => {
@@ -13,69 +14,68 @@ jest.mock("lodash.debounce", () =>
}) })
); );
jest.mock("../store/config.js", () => ({ base: "" })); jest.mock("../store/config.ts", () => ({ base: "" }));
describe("<LogEventSource />", () => { describe("<LogEventSource />", () => {
beforeEach(() => { beforeEach(() => {
// @ts-ignore
global.EventSource = EventSource; global.EventSource = EventSource;
window.scrollTo = jest.fn(); window.scrollTo = jest.fn();
const observe = jest.fn(); global.IntersectionObserver = jest.fn().mockImplementation(() => ({
const disconnect = jest.fn(); observe: jest.fn(),
global.IntersectionObserver = jest.fn(() => ({ disconnect: jest.fn(),
observe,
disconnect,
})); }));
debounce.mockClear();
mocked(debounce).mockClear();
}); });
function createLogEventSource({ hourStyle = "auto", searchFilter = null } = {}) { function createLogEventSource({
const localVue = createLocalVue(); hourStyle = "auto",
localVue.use(Vuex); searchFilter = null,
}: { hourStyle?: string; searchFilter?: string | null } = {}) {
localVue.component("log-viewer", LogViewer); const store = createStore({
state: { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } },
const state = { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } }; getters: {
const getters = {
allContainersById() { allContainersById() {
return { return {
abc: { state: "running" }, abc: { state: "running" },
}; };
}, },
}; },
const store = new Vuex.Store({
state,
getters,
}); });
return mount(LogEventSource, { return mount(LogEventSource, {
localVue, global: {
store, plugins: [store],
scopedSlots: { components: {
LogViewer,
},
},
slots: {
default: ` 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 () => { test("renders correctly", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
test("should connect to EventSource", async () => { test("should connect to EventSource", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1); expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
wrapper.destroy(); wrapper.unmount();
}); });
test("should close EventSource", async () => { test("should close EventSource", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
wrapper.destroy(); wrapper.unmount();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2); expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
}); });
@@ -121,7 +121,7 @@ describe("<LogEventSource />", () => {
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
data: `2019-06-12T10:55:42.459034602Z "This is a message."`, 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; const { key, ...messageWithoutKey } = message;
@@ -138,8 +138,10 @@ describe("<LogEventSource />", () => {
describe("render html correctly", () => { describe("render html correctly", () => {
const RealDate = Date; const RealDate = Date;
beforeAll(() => { beforeAll(() => {
// @ts-ignore
global.Date = class extends RealDate { global.Date = class extends RealDate {
constructor(arg) { constructor(arg: any | number) {
super(arg);
if (arg) { if (arg) {
return new RealDate(arg); return new RealDate(arg);
} else { } else {
@@ -158,11 +160,9 @@ describe("<LogEventSource />", () => {
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(` expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
<ul class="events medium"> `"<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>"`
<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 () => { test("should render messages with color", async () => {
@@ -173,11 +173,9 @@ describe("<LogEventSource />", () => {
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(` expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
<ul class="events medium"> `"<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>"`
<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 () => { test("should render messages with html entities", async () => {
@@ -188,11 +186,9 @@ describe("<LogEventSource />", () => {
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(` expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
<ul class="events medium"> `"<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>"`
<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 () => { test("should render dates with 12 hour style", async () => {
@@ -203,11 +199,9 @@ describe("<LogEventSource />", () => {
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(` expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
<ul class="events medium"> `"<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>"`
<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 () => { test("should render dates with 24 hour style", async () => {
@@ -218,11 +212,9 @@ describe("<LogEventSource />", () => {
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(` expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
<ul class="events medium"> `"<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>"`
<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 () => { test("should render messages with filter", async () => {
@@ -236,11 +228,9 @@ describe("<LogEventSource />", () => {
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(` expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
<ul class="events medium"> `"<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>"`
<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

@@ -5,105 +5,101 @@
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
import { toRefs, ref, watch, onUnmounted } from "vue";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import InfiniteLoader from "./InfiniteLoader";
import InfiniteLoader from "./InfiniteLoader.vue";
import config from "../store/config"; import config from "../store/config";
import containerMixin from "./mixins/container"; import useContainer from "../composables/container";
export default { const props = defineProps({
props: ["id"], id: {
mixins: [containerMixin], type: String,
name: "LogEventSource", required: true,
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 = [];
},
clear() {
this.messages = [];
},
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;
this.$emit("loading-more", true); const { id } = toRefs(props);
const to = this.messages[0].date;
const last = this.messages[299].date; const emit = defineEmits(["loading-more"]);
const delta = to - last;
interface LogEntry {
date: Date;
message: String;
key: String;
event?: String;
}
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 from = new Date(to.getTime() + delta);
const logs = await ( const logs = await (
await fetch(`${config.base}/api/logs?id=${this.id}&from=${from.toISOString()}&to=${to.toISOString()}`) await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
).text(); ).text();
if (logs) { if (logs) {
const newMessages = logs const newMessages = logs
.trim() .trim()
.split("\n") .split("\n")
.map((line) => this.parseMessage(line)); .map((line) => parseMessage(line));
this.messages.unshift(...newMessages); messages.value.unshift(...newMessages);
} }
this.$emit("loading-more", false); emit("loading-more", false);
}, }
parseMessage(data) {
function parseMessage(data: String): LogEntry {
let i = data.indexOf(" "); let i = data.indexOf(" ");
if (i == -1) { if (i == -1) {
i = data.length; i = data.length;
@@ -112,14 +108,36 @@ export default {
const date = new Date(key); const date = new Date(key);
const message = data.substring(i + 1); const message = data.substring(i + 1);
return { key, date, message }; return { key, date, message };
},
},
watch: {
id(newValue, oldValue) {
if (oldValue !== newValue) {
this.loadLogs();
} }
},
}, const { container } = useContainer(id);
};
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> </script>

View File

@@ -6,43 +6,33 @@
</li> </li>
</ul> </ul>
</template> </template>
<script>
import { mapState } from "vuex"; <script lang="ts" setup>
import { computed } from "vue";
import { useStore } from "vuex";
import RelativeTime from "./RelativeTime.vue";
import AnsiConvertor from "ansi-to-html"; import AnsiConvertor from "ansi-to-html";
import DOMPurify from "dompurify";
import RelativeTime from "./RelativeTime";
const ansiConvertor = new AnsiConvertor({ escapeXML: true }); const props = defineProps({
messages: Array,
if (window.trustedTypes && trustedTypes.createPolicy) {
trustedTypes.createPolicy("default", {
createHTML: (string, sink) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: true }),
}); });
const store = useStore();
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
function colorize(value) {
return ansiConvertor.toHtml(value).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>");
} }
export default { const settings = computed(() => store.state.settings);
props: ["messages"], const searchFilter = computed(() => store.state.searchFilter);
name: "LogViewer", const filtered = computed(() => {
components: { RelativeTime }, if (searchFilter && searchFilter.value) {
data() { const isSmartCase = searchFilter.value === searchFilter.value.toLowerCase();
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 { try {
const regex = isSmartCase ? new RegExp(searchFilter, "i") : new RegExp(searchFilter); const regex = isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value);
return messages return props.messages
.filter((d) => d.message.match(regex)) .filter((d) => d.message.match(regex))
.map((d) => ({ .map((d) => ({
...d, ...d,
@@ -51,15 +41,14 @@ export default {
} catch (e) { } catch (e) {
if (e instanceof SyntaxError) { if (e instanceof SyntaxError) {
console.info(`Ignoring SytaxError from search.`, e); console.info(`Ignoring SytaxError from search.`, e);
return messages; return props.messages;
} }
throw e; throw e;
} }
} }
return messages;
}, return props.messages;
}, });
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.events { .events {
@@ -108,9 +97,12 @@ export default {
.text { .text {
white-space: pre-wrap; white-space: pre-wrap;
&::before {
content: " ";
}
} }
::v-deep mark { :deep(mark) {
border-radius: 2px; border-radius: 2px;
background-color: var(--secondary-color); background-color: var(--secondary-color);
animation: pops 200ms ease-out; animation: pops 200ms ease-out;

View File

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

View File

@@ -41,7 +41,7 @@
</aside> </aside>
</template> </template>
<script> <script lang="ts">
import { mapGetters } from "vuex"; import { mapGetters } from "vuex";
export default { export default {

View File

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

View File

@@ -1,8 +1,8 @@
<template> <template>
<time :datetime="date.toISOString()">{{ date | relativeTime(locale) }}</time> <time :datetime="date.toISOString()">{{ relativeTime(date, locale) }}</time>
</template> </template>
<script> <script lang="ts">
import { mapState } from "vuex"; import { mapState } from "vuex";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import enGB from "date-fns/locale/en-GB"; import enGB from "date-fns/locale/en-GB";
@@ -27,7 +27,6 @@ export default {
}, },
name: "RelativeTime", name: "RelativeTime",
components: {}, components: {},
computed: { computed: {
...mapState(["settings"]), ...mapState(["settings"]),
locale() { locale() {
@@ -41,7 +40,7 @@ export default {
}; };
}, },
}, },
filters: { methods: {
relativeTime(date, locale) { relativeTime(date, locale) {
return formatRelative(date, new Date(), { locale }); return formatRelative(date, new Date(), { locale });
}, },

View File

@@ -17,7 +17,7 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { mapGetters } from "vuex"; import { mapGetters } from "vuex";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
@@ -45,7 +45,9 @@ export default {
}, },
mounted() { mounted() {
this.attachEvents(); this.attachEvents();
this.$once("hook:beforeDestroy", this.detachEvents); },
beforeUnmount() {
this.detachEvents();
}, },
watch: { watch: {
activeContainers() { activeContainers() {

View File

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

View File

@@ -11,7 +11,7 @@
@keyup.esc="resetSearch()" @keyup.esc="resetSearch()"
/> />
<span class="icon is-left"> <span class="icon is-left">
<search-icon /> <mdi-light-magnify />
</span> </span>
</p> </p>
</div> </div>
@@ -21,17 +21,13 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { mapActions, mapState } from "vuex"; import { mapActions, mapState } from "vuex";
import hotkeys from "hotkeys-js"; import hotkeys from "hotkeys-js";
import SearchIcon from "~icons/mdi-light/magnify";
export default { export default {
props: [], props: [],
name: "Search", name: "Search",
components: {
SearchIcon,
},
data() { data() {
return { return {
showSearch: false, showSearch: false,
@@ -47,7 +43,7 @@ export default {
this.resetSearch(); this.resetSearch();
}); });
}, },
beforeDestroy() { beforeUnmount() {
this.updateSearchFilter(""); this.updateSearchFilter("");
hotkeys.unbind("command+f, ctrl+f, esc"); hotkeys.unbind("command+f, ctrl+f, esc");
}, },

View File

@@ -10,20 +10,20 @@
</div> </div>
<div class="column is-narrow has-text-right px-1"> <div class="column is-narrow has-text-right px-1">
<button <button
class="button is-small is-rounded is-settings-control pl-1 pr-1" class="button is-rounded is-settings-control"
@click="$emit('search')" @click="$emit('search')"
title="Search containers (⌘ + k, ⌃k)" title="Search containers (⌘ + k, ⌃k)"
> >
<search-icon /> <span class="icon">
<mdi-light-magnify />
</span>
</button> </button>
</div> </div>
<div class="column is-narrow has-text-right px-0"> <div class="column is-narrow has-text-right px-0">
<router-link <router-link :to="{ name: 'settings' }" active-class="is-active" class="button is-rounded is-settings-control">
:to="{ name: 'settings' }" <span class="icon">
active-class="is-active" <mdi-light-cog />
class="button is-small is-rounded is-settings-control pl-1 pr-1" </span>
>
<settings-icon />
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -46,7 +46,7 @@
v-show="!activeContainersById[item.id]" v-show="!activeContainersById[item.id]"
title="Pin as column" title="Pin as column"
> >
<columns-icon /> <cil-columns />
</span> </span>
</div> </div>
</div> </div>
@@ -56,21 +56,13 @@
</aside> </aside>
</template> </template>
<script> <script lang="ts">
import { mapActions, mapGetters, mapState } from "vuex"; import { mapActions, mapGetters } from "vuex";
import SearchIcon from "~icons/mdi-light/magnify";
import SettingsIcon from "~icons/mdi-light/cog";
import ColumnsIcon from "~icons/cil/columns";
export default { export default {
props: [], props: [],
name: "SideMenu", name: "SideMenu",
components: {
SearchIcon,
SettingsIcon,
ColumnsIcon,
},
data() { data() {
return {}; return {};
}, },

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,14 @@
// 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>
<div class=\\"bounce2\\"></div>
<div class=\\"bounce3\\"></div>
</div>
</div>
<ul class=\\"events medium\\"></ul>
</div>"
`;

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,9 @@
import { Ref , computed} from "vue";
import { useStore } from "vuex";
export default function useContainer(id: Ref<string>) {
const store = useStore();
const container = computed(() => store.getters.allContainersById[id.value]);
return { container };
}

View File

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

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,27 +1,13 @@
import Vue from "vue"; import "./styles.scss";
import VueRouter from "vue-router"; import { createApp } from "vue";
import Meta from "vue-meta"; import { createRouter, createWebHistory } from "vue-router";
import Switch from "buefy/dist/esm/switch"; import { Autocomplete, Button, Dropdown, Switch, Radio, Field, Tooltip, Modal, Config } from "@oruga-ui/oruga-next";
import Radio from "buefy/dist/esm/radio"; import { bulmaConfig } from "@oruga-ui/theme-bulma";
import Field from "buefy/dist/esm/field";
import Modal from "buefy/dist/esm/modal";
import Tooltip from "buefy/dist/esm/tooltip";
import Autocomplete from "buefy/dist/esm/autocomplete";
import store from "./store"; import store from "./store";
import config from "./store/config"; import config from "./store/config";
import App from "./App.vue"; import App from "./App.vue";
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages"; 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(Tooltip);
Vue.use(Autocomplete);
const routes = [ const routes = [
{ {
path: "/", path: "/",
@@ -61,14 +47,21 @@ const routes = [
}, },
]; ];
const router = new VueRouter({ const router = createRouter({
mode: "history", history: createWebHistory(`${config.base}/`),
base: config.base + "/",
routes, routes,
}); });
new Vue({ createApp(App)
router, .use(router)
store, .use(store)
render: (h) => h(App), .use(Autocomplete)
}).$mount("#app"); .use(Button)
.use(Dropdown)
.use(Switch)
.use(Tooltip)
.use(Modal)
.use(Radio)
.use(Field)
.use(Config, bulmaConfig)
.mount("#app");

View File

@@ -5,10 +5,11 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { mapGetters } from "vuex"; import { mapGetters } from "vuex";
import Search from "../components/Search"; import Search from "../components/Search.vue";
import LogContainer from "../components/LogContainer"; import LogContainer from "../components/LogContainer.vue";
import { setTitle } from "@/composables/title";
export default { export default {
props: ["id"], props: ["id"],
@@ -17,19 +18,13 @@ export default {
LogContainer, LogContainer,
Search, Search,
}, },
data() { created() {
return { setTitle("loading");
title: "loading",
};
},
metaInfo() {
return {
title: this.title,
};
}, },
mounted() { mounted() {
if (this.allContainersById[this.id]) { if (this.allContainersById[this.id]) {
this.title = this.allContainersById[this.id].name; setTitle(this.allContainersById[this.id].name);
} }
}, },
computed: { computed: {
@@ -37,10 +32,10 @@ export default {
}, },
watch: { watch: {
id() { id() {
this.title = this.allContainersById[this.id].name; setTitle(this.allContainersById[this.id].name);
}, },
allContainersById() { allContainersById() {
this.title = this.allContainersById[this.id].name; setTitle(this.allContainersById[this.id].name);
}, },
}, },
}; };

View File

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

View File

@@ -76,12 +76,12 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { mapState } from "vuex"; import { mapState } from "vuex";
import SearchIcon from "~icons/mdi-light/magnify";
import PastTime from "../components/PastTime";
import config from "../store/config";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import SearchIcon from "~icons/mdi-light/magnify";
import PastTime from "../components/PastTime.vue";
import config from "../store/config";
export default { export default {
name: "Index", name: "Index",

View File

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

View File

@@ -11,13 +11,12 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { setTitle } from "@/composables/title";
export default { export default {
name: "PageNotFound", name: "PageNotFound",
metaInfo() { setup() {
return { setTitle("Page not found");
title: "404 Error",
};
}, },
}; };
</script> </script>

View File

@@ -25,16 +25,22 @@
<div class="item"> <div class="item">
<div class="columns is-vcentered"> <div class="columns is-vcentered">
<div class="column is-narrow"> <div class="column is-narrow">
<b-field> <o-field>
<b-radio-button <o-dropdown v-model="hourStyle" aria-role="list">
v-model="hourStyle" <template v-slot:trigger>
:native-value="value" <o-button variant="primary" type="button">
v-for="value in ['auto', '12', '24']" <span class="is-capitalized">{{ hourStyle }}</span>
:key="value" <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> <span class="is-capitalized">{{ value }}</span>
</b-radio-button> </o-dropdown-item>
</b-field> </o-dropdown>
</o-field>
</div> </div>
<div class="column"> <div class="column">
By default, Dozzle will use your browser's locale to format time. You can force to 12 or 24 hour style. By default, Dozzle will use your browser's locale to format time. You can force to 12 or 24 hour style.
@@ -42,26 +48,37 @@
</div> </div>
<div class="item"> <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>
<div class="item"> <div class="item">
<b-switch v-model="showTimestamp"> Show timestamps </b-switch> <o-switch v-model="showTimestamp"> Show timestamps </o-switch>
</div> </div>
</div> </div>
<div class="item"> <div class="item">
<div class="columns is-vcentered"> <div class="columns is-vcentered">
<div class="column is-narrow"> <div class="column is-narrow">
<b-field> <o-field>
<b-radio-button <o-dropdown v-model="size" aria-role="list">
v-model="size" <template v-slot:trigger>
:native-value="value" <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']" v-for="value in ['small', 'medium', 'large']"
:key="value" :key="value"
> >
<span class="is-capitalized">{{ value }}</span> <span class="is-capitalized">{{ value }}</span>
</b-radio-button> </o-dropdown-item>
</b-field> </o-dropdown>
</o-field>
</div> </div>
<div class="column">Font size to use for logs</div> <div class="column">Font size to use for logs</div>
</div> </div>
@@ -73,26 +90,27 @@
</div> </div>
<div class="item"> <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> Enable searching with Dozzle using <code>command+f</code> or <code>ctrl+f</code>
</b-switch> </o-switch>
</div> </div>
<div class="item"> <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>
<div class="item"> <div class="item">
<b-switch v-model="lightTheme"> Use light theme </b-switch> <o-switch v-model="lightTheme"> Use light theme </o-switch>
</div> </div>
</section> </section>
</div> </div>
</template> </template>
<script> <script lang="ts">
import gt from "semver/functions/gt"; import gt from "semver/functions/gt";
import { mapActions, mapState } from "vuex"; import { mapActions, mapState } from "vuex";
import config from "../store/config"; import config from "../store/config";
import { setTitle } from "@/composables/title";
export default { export default {
props: [], props: [],
@@ -105,6 +123,7 @@ export default {
}; };
}, },
async created() { async created() {
setTitle("Settings");
const releases = await (await fetch("https://api.github.com/repos/amir20/dozzle/releases")).json(); const releases = await (await fetch("https://api.github.com/repos/amir20/dozzle/releases")).json();
if (this.currentVersion !== "master") { if (this.currentVersion !== "master") {
this.hasUpdate = gt(releases[0].tag_name, this.currentVersion); this.hasUpdate = gt(releases[0].tag_name, this.currentVersion);
@@ -113,11 +132,6 @@ export default {
} }
this.nextRelease = releases[0]; this.nextRelease = releases[0];
}, },
metaInfo() {
return {
title: "Settings",
};
},
methods: { methods: {
...mapActions({ ...mapActions({
updateSetting: "UPDATE_SETTING", updateSetting: "UPDATE_SETTING",

View File

@@ -1,7 +1,7 @@
<template></template> <template></template>
<script> <script lang="ts">
import { mapActions, mapGetters, mapState } from "vuex"; import { mapGetters } from "vuex";
export default { export default {
props: [], props: [],
name: "Show", name: "Show",

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,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 }}") { if (config.version == "{{ .Version }}") {
config.version = "master"; config.version = "master";
config.base = ""; config.base = "";
@@ -9,5 +11,4 @@ if (config.version == "{{ .Version }}") {
config.authorizationNeeded = config.authorizationNeeded === "true"; config.authorizationNeeded = config.authorizationNeeded === "true";
config.secured = config.secured === "true"; config.secured = config.secured === "true";
} }
export default config; export default config;

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;

119
assets/store/index.ts Normal file
View File

@@ -0,0 +1,119 @@
import { createStore } from "vuex";
import storage from "store/dist/store.modern";
import { DEFAULT_SETTINGS, DOZZLE_SETTINGS_KEY } from "./settings";
import config from "./config";
storage.set(DOZZLE_SETTINGS_KEY, { ...DEFAULT_SETTINGS, ...storage.get(DOZZLE_SETTINGS_KEY) });
interface Container {
id: string;
name: string;
state: string;
stat: ContainerStat;
}
interface ContainerStat {
cpu: number;
memory: number;
memoryUsage: number;
}
type IdToContainer = { [id: string]: Container };
function allContainersById(containers: Container[]): IdToContainer {
return containers.reduce((map, obj) => {
map[obj.id] = obj;
return map;
}, {} as IdToContainer);
}
const store = createStore({
state: {
containers: [] as Container[],
activeContainerIds: [] as string[],
searchFilter: null,
settings: storage.get(DOZZLE_SETTINGS_KEY),
authorizationNeeded: config.authorizationNeeded,
},
mutations: {
SET_CONTAINERS(state, containers) {
const containersById = allContainersById(containers);
containers.forEach((container: Container) => {
container.stat =
containersById[container.id] && containersById[container.id].stat
? containersById[container.id].stat
: { memoryUsage: 0, cpu: 0, memory: 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;
},
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)) {
container[key] = value;
}
},
},
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:
}
},
},
getters: {
allContainersById({ containers }) {
return allContainersById(containers);
},
visibleContainers({ containers, settings: { showAllContainers } }) {
const filter = showAllContainers ? () => true : (c: Container) => 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);
}
export default store;

View File

@@ -1,5 +1,5 @@
@charset "utf-8"; @charset "utf-8";
@import "~bulma/sass/utilities/initial-variables.sass"; @import "bulma/sass/utilities/initial-variables.sass";
$body-background-color: var(--body-background-color); $body-background-color: var(--body-background-color);
@@ -27,14 +27,15 @@ $link-active: $grey-dark;
$dark-toolbar-color: rgba($black-bis, 0.7); $dark-toolbar-color: rgba($black-bis, 0.7);
$light-toolbar-color: rgba($grey-darker, 0.7); $light-toolbar-color: rgba($grey-darker, 0.7);
@import "~bulma"; @import "bulma/bulma.sass";
@import "../node_modules/splitpanes/dist/splitpanes.css"; @import "@oruga-ui/theme-bulma/dist/scss/components/utils/all.scss";
@import "~buefy/src/scss/utils/_all"; @import "@oruga-ui/theme-bulma/dist/scss/components/autocomplete.scss";
@import "~buefy/src/scss/components/_switch"; @import "@oruga-ui/theme-bulma/dist/scss/components/button.scss";
@import "~buefy/src/scss/components/_radio"; @import "@oruga-ui/theme-bulma/dist/scss/components/modal.scss";
@import "~buefy/src/scss/components/_modal"; @import "@oruga-ui/theme-bulma/dist/scss/components/switch.scss";
@import "~buefy/src/scss/components/_tooltip"; @import "@oruga-ui/theme-bulma/dist/scss/components/tooltip.scss";
@import "~buefy/src/scss/components/_autocomplete"; @import "@oruga-ui/theme-bulma/dist/scss/components/dropdown.scss";
@import "splitpanes/dist/splitpanes.css";
html { html {
--scheme-main: #{$black}; --scheme-main: #{$black};
@@ -161,3 +162,7 @@ html.has-custom-scrollbars {
.modal { .modal {
z-index: 1000; z-index: 1000;
} }
.button .button-wrapper > span {
display: contents;
}

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,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,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: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 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_FILTER=name=custom_base
- DOZZLE_BASE=/foobarbase - DOZZLE_BASE=/foobarbase
- DOZZLE_NO_ANALYTICS=1 - DOZZLE_NO_ANALYTICS=1
ports:
- "8080:8080"
build: build:
context: .. context: ..
dozzle: dozzle:
@@ -17,18 +19,20 @@ services:
environment: environment:
- DOZZLE_FILTER=name=dozzle - DOZZLE_FILTER=name=dozzle
- DOZZLE_NO_ANALYTICS=1 - DOZZLE_NO_ANALYTICS=1
ports:
- "9090:8080"
build: build:
context: .. context: ..
integration: cypress:
build: build:
context: . context: .
command: yarn test working_dir: /e2e
volumes: volumes:
- ./__tests__:/app/__tests__ - ./cypress:/e2e/cypress
- ./cypress.json:/e2e/cypress.json
environment: environment:
- DEFAULT_URL=http://dozzle:8080/ - CYPRESS_DOZZLE_DEFAULT=http://dozzle:8080/
- CUSTOM_URL=http://custom_base:8080/foobarbase - CYPRESS_CUSTOM_DEFAULT=http://custom_base:8080/foobarbase
- DOZZLE_NO_ANALYTICS=1
depends_on: depends_on:
- dozzle - dozzle
- custom_base - 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

View File

@@ -12,6 +12,8 @@
"secured": "{{ .Secured }}" "secured": "{{ .Secured }}"
} }
</script> </script>
<link rel="icon" href="/assets/favicon.svg" />
<script type="module" src="/assets/main.ts"></script>
</head> </head>
<body> <body>
<svg <svg

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": "^11.0.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 +1,9 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = { module.exports = {
clearMocks: true, preset: "ts-jest",
testEnvironment: "jsdom", testEnvironment: "jsdom",
moduleFileExtensions: ["js", "json", "vue"], testPathIgnorePatterns: ["node_modules", "<rootDir>/integration/", "<rootDir>/e2e/"],
coveragePathIgnorePatterns: ["node_modules"],
testPathIgnorePatterns: ["node_modules", "<rootDir>/integration/"],
transformIgnorePatterns: ["node_modules"],
watchPathIgnorePatterns: ["<rootDir>/node_modules/"],
snapshotSerializers: ["jest-serializer-vue"],
transform: { transform: {
".*\\.vue$": "vue-jest", "^.+\\.vue$": "@vue/vue3-jest",
"^.+\\.js$": "babel-jest",
}, },
}; };

View File

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

12
main.go
View File

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

View File

@@ -11,64 +11,56 @@
"url": "git+https://github.com/amir20/dozzle.git" "url": "git+https://github.com/amir20/dozzle.git"
}, },
"license": "ISC", "license": "ISC",
"author": "", "author": "Amir Raminfar <findamir@gmail.com>",
"scripts": { "scripts": {
"watch": "npm-run-all -p watch:*", "watch:assets": "vite --open",
"watch:assets": "webpack --mode=development --watch",
"watch:server": "LIVE_FS=true reflex -c .reflex", "watch:server": "LIVE_FS=true reflex -c .reflex",
"dev": "make fake_static && npm-run-all -p dev-server watch:server", "dev": "make fake_assets && npm-run-all -p watch:assets watch:server",
"dev-server": "webpack serve --mode=development", "build": "vite build",
"build": "rm -rf static && webpack --mode=production --progress",
"clean": "rm -rf static",
"release": "release-it", "release": "release-it",
"test": "TZ=UTC jest", "test": "TZ=UTC jest",
"postinstall": "husky install" "postinstall": "husky install"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.15.8", "@iconify-json/carbon": "^1.0.9",
"@babel/plugin-transform-runtime": "^7.15.8", "@iconify-json/cil": "^1.0.1",
"@vue/component-compiler-utils": "^3.3.0",
"@vue/test-utils": "^1.2.2",
"ansi-to-html": "^0.7.2",
"autoprefixer": "^10.4.0",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^27.3.1",
"babel-preset-env": "^1.7.0",
"buefy": "^0.9.10",
"bulma": "^0.9.3",
"caniuse-lite": "^1.0.30001272",
"css-loader": "^6.5.0",
"date-fns": "^2.25.0",
"dompurify": "^2.3.3",
"eventsourcemock": "^2.0.0",
"fuzzysort": "^1.1.4",
"hotkeys-js": "^3.8.7",
"html-webpack-plugin": "^5.5.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"mini-css-extract-plugin": "^2.4.3",
"postcss": "^8.3.11",
"postcss-loader": "^6.2.0",
"sass": "^1.43.4",
"sass-loader": "^12.3.0",
"semver": "^7.3.5",
"splitpanes": "^2.3.8",
"store": "^2.0.12",
"unplugin-icons": "^0.12.18",
"vue": "^2.6.14",
"vue-loader": "^15.9.8",
"vue-meta": "^2.4.0",
"vue-router": "^3.5.3",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.6.14",
"vuex": "^3.6.2",
"webpack": "^5.61.0",
"webpack-cli": "^4.9.1",
"webpack-pwa-manifest": "^4.3.0"
},
"devDependencies": {
"@iconify-json/mdi-light": "^1.0.1", "@iconify-json/mdi-light": "^1.0.1",
"@iconify-json/octicon": "^1.0.5", "@iconify-json/octicon": "^1.0.5",
"@oruga-ui/oruga-next": "^0.4.7",
"@oruga-ui/theme-bulma": "^0.1.3",
"@vitejs/plugin-vue": "^1.9.4",
"@vueuse/core": "^6.9.0",
"ansi-to-html": "^0.7.2",
"autoprefixer": "^10.4.0",
"bulma": "^0.9.3",
"date-fns": "^2.25.0",
"fuzzysort": "^1.1.4",
"hotkeys-js": "^3.8.7",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"sass": "^1.43.4",
"semver": "^7.3.5",
"splitpanes": "^3.0.6",
"store": "^2.0.12",
"typescript": "^4.4.4",
"unplugin-auto-import": "^0.4.14",
"unplugin-icons": "^0.12.18",
"unplugin-vue-components": "^0.17.2",
"vite": "^2.6.13",
"vue": "^3.2.21",
"vue-router": "^4.0.12",
"vuex": "^4.0.2"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@types/jest": "^27.0.2",
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.throttle": "^4.1.6",
"@vue/compiler-sfc": "^3.2.21",
"@vue/test-utils": "^2.0.0-rc.16",
"@vue/vue3-jest": "^27.0.0-alpha.3",
"eventsourcemock": "^2.0.0",
"husky": "^7.0.4", "husky": "^7.0.4",
"jest": "^27.3.1", "jest": "^27.3.1",
"jest-serializer-vue": "^2.0.2", "jest-serializer-vue": "^2.0.2",
@@ -76,9 +68,7 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"release-it": "^14.11.6", "release-it": "^14.11.6",
"vue-hot-reload-api": "^2.3.4", "ts-jest": "^27.0.7"
"vue-jest": "^3.0.7",
"webpack-dev-server": "^4.4.0"
}, },
"lint-staged": { "lint-staged": {
"*.{js,vue,css}": [ "*.{js,vue,css}": [

4856
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ESNext",
"lib": ["DOM", "ESNext"],
"strict": true,
"esModuleInterop": true,
"incremental": false,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["assets/*"]
}
},
"include": ["assets/**/*.ts", "assets/**/*.d.ts", "assets/**/*.vue"],
"exclude": ["dist", "node_modules"]
}

48
vite.config.ts Normal file
View File

@@ -0,0 +1,48 @@
import path from "path";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Icons from "unplugin-icons/vite";
import Components from "unplugin-vue-components/vite";
import IconsResolver from "unplugin-icons/resolver";
export default defineConfig(({ mode }) => ({
resolve: {
alias: {
"@/": `${path.resolve(__dirname, "assets")}/`,
},
},
base: mode === "production" ? "/<__BASE__>/" : "/",
plugins: [
vue(),
Icons({
autoInstall: true,
}),
Components({
dirs: ["assets/components"],
resolvers: [
IconsResolver({
componentPrefix: "",
}),
],
dts: "assets/components.d.ts",
}),
htmlPlugin(mode),
],
server: {
proxy: {
"/api": {
target: "http://localhost:8080/",
},
},
},
}));
const htmlPlugin = (mode) => {
return {
name: "html-transform",
transformIndexHtml(html) {
return mode === "production" ? html.replaceAll("/<__BASE__>", "{{ .Base }}") : html;
},
};
};

View File

@@ -1,7 +1,7 @@
/* snapshot: Test_createRoutes_foobar */ /* snapshot: Test_createRoutes_foobar */
HTTP/1.1 200 OK HTTP/1.1 200 OK
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
foo page foo page
@@ -9,7 +9,7 @@ foo page
/* snapshot: Test_createRoutes_index */ /* snapshot: Test_createRoutes_index */
HTTP/1.1 200 OK HTTP/1.1 200 OK
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
index page index page
@@ -17,7 +17,7 @@ index page
/* snapshot: Test_createRoutes_redirect */ /* snapshot: Test_createRoutes_redirect */
HTTP/1.1 301 Moved Permanently HTTP/1.1 301 Moved Permanently
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/html; charset=utf-8 Content-Type: text/html; charset=utf-8
Location: /foobar/ Location: /foobar/
@@ -26,7 +26,7 @@ Location: /foobar/
/* snapshot: Test_createRoutes_redirect_with_auth */ /* snapshot: Test_createRoutes_redirect_with_auth */
HTTP/1.1 307 Temporary Redirect HTTP/1.1 307 Temporary Redirect
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/html; charset=utf-8 Content-Type: text/html; charset=utf-8
Location: /foobar/login Location: /foobar/login
@@ -35,7 +35,7 @@ Location: /foobar/login
/* snapshot: Test_createRoutes_username_password */ /* snapshot: Test_createRoutes_username_password */
HTTP/1.1 307 Temporary Redirect HTTP/1.1 307 Temporary Redirect
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/html; charset=utf-8 Content-Type: text/html; charset=utf-8
Location: /login Location: /login
@@ -44,7 +44,7 @@ Location: /login
/* snapshot: Test_createRoutes_username_password_invalid */ /* snapshot: Test_createRoutes_username_password_invalid */
HTTP/1.1 401 Unauthorized HTTP/1.1 401 Unauthorized
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
@@ -56,7 +56,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
@@ -66,7 +66,7 @@ data: end of stream
/* snapshot: Test_createRoutes_version */ /* snapshot: Test_createRoutes_version */
HTTP/1.1 200 OK HTTP/1.1 200 OK
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script' Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
dev dev

View File

@@ -6,7 +6,7 @@ import (
func cspHeaders(next http.Handler) http.Handler { func cspHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script'") w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com;")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }

View File

@@ -1,86 +0,0 @@
const path = require("path");
const { VueLoaderPlugin } = require("vue-loader");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const WebpackPwaManifest = require("webpack-pwa-manifest");
const Icons = require("unplugin-icons/webpack");
module.exports = (env, argv) => ({
stats: { children: false, entrypoints: false, modules: false },
performance: {
maxAssetSize: 350000,
maxEntrypointSize: 600000,
},
devtool: argv.mode !== "production" ? "inline-cheap-source-map" : false,
entry: ["./assets/main.js", "./assets/styles.scss"],
output: {
path: path.resolve(__dirname, "./static"),
filename: "[name].js",
publicPath: argv.mode === "production" ? "{{ .Base }}" : "/",
},
module: {
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
},
{
test: /\.(sass|scss|css)$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
"css-loader",
{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: [require("autoprefixer")],
},
},
},
"sass-loader",
],
},
],
},
plugins: [
new VueLoaderPlugin(),
new MiniCssExtractPlugin(),
new HtmlWebpackPlugin({
hash: true,
template: "assets/index.ejs",
scriptLoading: "defer",
favicon: "assets/favicon.svg",
}),
new WebpackPwaManifest({
name: "Dozzle Log Viewer",
short_name: "Dozzle",
theme_color: "#222",
background_color: "#222",
display: "standalone",
}),
Icons({
compiler: "vue2",
autoInstall: true,
scale: 2,
}),
],
resolve: {
alias: {
vue$: "vue/dist/vue.runtime.esm.js",
},
extensions: ["*", ".js", ".vue", ".json"],
},
devServer: {
port: 8081,
hot: true,
open: true,
historyApiFallback: true,
proxy: {
"/api": {
target: "http://localhost:8080",
},
},
},
});