1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-31 18:17:23 +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,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>
<splitpanes @resized="onResized($event)">
<pane min-size="10" :size="settings.menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
<pane min-size="10" :size="menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
<side-menu @search="showFuzzySearch"></side-menu>
</pane>
<pane min-size="10">
@@ -18,7 +18,7 @@
show-title
scrollable
closable
@close="removeActiveContainer(other)"
@close="store.dispatch('REMOVE_ACTIVE_CONTAINER', other)"
></log-container>
</pane>
</template>
@@ -27,128 +27,96 @@
</splitpanes>
<button
@click="collapseNav = !collapseNav"
class="button is-small is-rounded is-settings-control"
class="button is-rounded is-settings-control"
:class="{ collapsed: collapseNav }"
id="hide-nav"
v-if="!isMobile && !authorizationNeeded"
>
<span class="icon ml-2" v-if="collapseNav">
<chevron-right-icon />
<mdi-light-chevron-right />
</span>
<span class="icon" v-else>
<chevron-left-icon />
<mdi-light-chevron-left />
</span>
</button>
</main>
</template>
<script>
import { mapActions, mapGetters, mapState } from "vuex";
<script lang="ts" setup>
import { Splitpanes, Pane } from "splitpanes";
import { ref, onMounted, watchEffect, toRefs, computed, watch } from "vue";
import { useStore } from "vuex";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import hotkeys from "hotkeys-js";
import { setTitle } from "./composables/title";
import { isMobile } from "./composables/mediaQuery";
import LogContainer from "./components/LogContainer";
import SideMenu from "./components/SideMenu";
import MobileMenu from "./components/MobileMenu";
import FuzzySearchModal from "./components/FuzzySearchModal.vue";
import LogContainer from "./components/LogContainer.vue";
import SideMenu from "./components/SideMenu.vue";
import MobileMenu from "./components/MobileMenu.vue";
import PastTime from "./components/PastTime";
import FuzzySearchModal from "./components/FuzzySearchModal";
const collapseNav = ref(false);
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";
import ChevronRightIcon from "~icons/mdi-light/chevron-right";
onMounted(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars");
}
if (lightTheme.value) {
document.documentElement.setAttribute("data-theme", "light");
}
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");
}
if (this.hasLightTheme) {
document.documentElement.setAttribute("data-theme", "light");
}
this.menuWidth = this.settings.menuWidth;
hotkeys("command+k, ctrl+k", (event, handler) => {
event.preventDefault();
this.showFuzzySearch();
});
},
watch: {
hasSmallerScrollbars(newValue, oldValue) {
if (newValue) {
document.documentElement.classList.add("has-custom-scrollbars");
} else {
document.documentElement.classList.remove("has-custom-scrollbars");
}
},
hasLightTheme(newValue, oldValue) {
if (newValue) {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
},
visibleContainers() {
this.title = `${this.visibleContainers.length} containers`;
},
},
computed: {
...mapState(["isMobile", "settings", "containers", "authorizationNeeded"]),
...mapGetters(["visibleContainers", "activeContainers"]),
hasSmallerScrollbars() {
return this.settings.smallerScrollbars;
},
hasLightTheme() {
return this.settings.lightTheme;
},
},
methods: {
...mapActions({
removeActiveContainer: "REMOVE_ACTIVE_CONTAINER",
updateSetting: "UPDATE_SETTING",
}),
onResized(e) {
if (e.length == 2) {
const menuWidth = e[0].size;
this.updateSetting({ menuWidth });
}
},
showFuzzySearch() {
this.$buefy.modal.open({
parent: this,
component: FuzzySearchModal,
animation: "false",
width: 600,
});
},
},
};
hotkeys("command+k, ctrl+k", (event, handler) => {
event.preventDefault();
showFuzzySearch();
});
});
watchEffect(() => {
setTitle(`${visibleContainers.value.length} containers`);
});
watchEffect(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars");
} else {
document.documentElement.classList.remove("has-custom-scrollbars");
}
if (lightTheme.value) {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
});
function showFuzzySearch() {
oruga.modal.open({
// parent: this,
component: FuzzySearchModal,
animation: "false",
width: 600,
active: true,
});
}
function onResized(e) {
if (e.length == 2) {
const menuWidth = e[0].size;
store.dispatch("UPDATE_SETTING", { menuWidth });
}
}
</script>
<style scoped lang="scss">
::v-deep .splitpanes--vertical > .splitpanes__splitter {
:deep(.splitpanes--vertical > .splitpanes__splitter) {
min-width: 3px;
background: var(--border-color);
&:hover {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,59 +8,62 @@
<div class="column is-narrow is-paddingless">
<container-stat :stat="container.stat" :state="container.state"></container-stat>
</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>
</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>
<log-actions-toolbar :container="container" :onClearClicked="onClearClicked"></log-actions-toolbar>
</template>
<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>
</scrollable-view>
</template>
<script>
import LogViewerWithSource from "./LogViewerWithSource";
import LogActionsToolbar from "./LogActionsToolbar";
import ScrollableView from "./ScrollableView";
import ContainerTitle from "./ContainerTitle";
import ContainerStat from "./ContainerStat";
import containerMixin from "./mixins/container";
<script lang="ts" setup>
import { ref, toRefs } from "vue";
import useContainer from "../composables/container";
export default {
mixins: [containerMixin],
props: {
id: {
type: String,
},
showTitle: {
type: Boolean,
default: false,
},
scrollable: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: false,
},
const props = defineProps({
id: {
type: String,
required: true,
},
name: "LogContainer",
components: {
LogViewerWithSource,
LogActionsToolbar,
ScrollableView,
ContainerTitle,
ContainerStat,
showTitle: {
type: Boolean,
default: false,
},
methods: {
onClearClicked() {
this.$refs.logViewer.clear();
},
scrollable: {
type: Boolean,
default: false,
},
};
closable: {
type: Boolean,
default: false,
},
});
const { id } = toRefs(props);
const { container } = useContainer(id);
const viewer = ref<HTMLElement>();
function onClearClicked() {
viewer.value?.clear();
}
</script>
<style lang="scss" scoped>
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 EventSource from "eventsourcemock";
import { sources } from "eventsourcemock";
import { shallowMount, mount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import LogEventSource from "./LogEventSource.vue";
import LogViewer from "./LogViewer.vue";
import { mocked } from "ts-jest/utils";
jest.mock("lodash.debounce", () =>
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 />", () => {
beforeEach(() => {
// @ts-ignore
global.EventSource = EventSource;
window.scrollTo = jest.fn();
const observe = jest.fn();
const disconnect = jest.fn();
global.IntersectionObserver = jest.fn(() => ({
observe,
disconnect,
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
disconnect: jest.fn(),
}));
debounce.mockClear();
mocked(debounce).mockClear();
});
function createLogEventSource({ hourStyle = "auto", searchFilter = null } = {}) {
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.component("log-viewer", LogViewer);
const state = { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } };
const getters = {
allContainersById() {
return {
abc: { state: "running" },
};
function createLogEventSource({
hourStyle = "auto",
searchFilter = null,
}: { hourStyle?: string; searchFilter?: string | null } = {}) {
const store = createStore({
state: { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } },
getters: {
allContainersById() {
return {
abc: { state: "running" },
};
},
},
};
const store = new Vuex.Store({
state,
getters,
});
return mount(LogEventSource, {
localVue,
store,
scopedSlots: {
global: {
plugins: [store],
components: {
LogViewer,
},
},
slots: {
default: `
<log-viewer :messages="props.messages"></log-viewer>
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
`,
},
propsData: { id: "abc" },
props: { id: "abc" },
});
}
test("renders correctly", async () => {
const wrapper = createLogEventSource();
expect(wrapper.element).toMatchSnapshot();
expect(wrapper.html()).toMatchSnapshot();
});
test("should connect to EventSource", async () => {
const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
wrapper.destroy();
wrapper.unmount();
});
test("should close EventSource", async () => {
const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
wrapper.destroy();
wrapper.unmount();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
});
@@ -121,7 +121,7 @@ describe("<LogEventSource />", () => {
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
});
const [message, _] = wrapper.findComponent(LogViewer).vm.messages;
const [message, _] = wrapper.getComponent(LogViewer).vm.messages;
const { key, ...messageWithoutKey } = message;
@@ -138,8 +138,10 @@ describe("<LogEventSource />", () => {
describe("render html correctly", () => {
const RealDate = Date;
beforeAll(() => {
// @ts-ignore
global.Date = class extends RealDate {
constructor(arg) {
constructor(arg: any | number) {
super(arg);
if (arg) {
return new RealDate(arg);
} else {
@@ -158,11 +160,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">"This is a message."</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">\\"This is a message.\\"</span></li></ul>"`
);
});
test("should render messages with color", async () => {
@@ -173,11 +173,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text"><span style="color:#000">black<span style="color:#AAA">white</span></span></span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span></li></ul>"`
);
});
test("should render messages with html entities", async () => {
@@ -188,11 +186,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">&lt;test&gt;foo bar&lt;/test&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">&lt;test&gt;foo bar&lt;/test&gt;</span></li></ul>"`
);
});
test("should render dates with 12 hour style", async () => {
@@ -203,11 +199,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 11:55:42 PM</time></span> <span class="text">&lt;test&gt;foo bar&lt;/test&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\">today at 11:55:42 PM</time></span><span class=\\"text\\">&lt;test&gt;foo bar&lt;/test&gt;</span></li></ul>"`
);
});
test("should render dates with 24 hour style", async () => {
@@ -218,11 +212,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 23:55:42</time></span> <span class="text">&lt;test&gt;foo bar&lt;/test&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\">today at 23:55:42</time></span><span class=\\"text\\">&lt;test&gt;foo bar&lt;/test&gt;</span></li></ul>"`
);
});
test("should render messages with filter", async () => {
@@ -236,11 +228,9 @@ describe("<LogEventSource />", () => {
});
await wrapper.vm.$nextTick();
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
<ul class="events medium">
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">This is a <mark>test</mark> &lt;hi&gt;&lt;/hi&gt;</span></li>
</ul>
`);
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">This is a <mark>test</mark> &lt;hi&gt;&lt;/hi&gt;</span></li></ul>"`
);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +1,13 @@
import Vue from "vue";
import VueRouter from "vue-router";
import Meta from "vue-meta";
import Switch from "buefy/dist/esm/switch";
import Radio from "buefy/dist/esm/radio";
import Field from "buefy/dist/esm/field";
import Modal from "buefy/dist/esm/modal";
import Tooltip from "buefy/dist/esm/tooltip";
import Autocomplete from "buefy/dist/esm/autocomplete";
import "./styles.scss";
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { Autocomplete, Button, Dropdown, Switch, Radio, Field, Tooltip, Modal, Config } from "@oruga-ui/oruga-next";
import { bulmaConfig } from "@oruga-ui/theme-bulma";
import store from "./store";
import config from "./store/config";
import App from "./App.vue";
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages";
Vue.use(VueRouter);
Vue.use(Meta);
Vue.use(Switch);
Vue.use(Radio);
Vue.use(Field);
Vue.use(Modal);
Vue.use(Tooltip);
Vue.use(Autocomplete);
const routes = [
{
path: "/",
@@ -61,14 +47,21 @@ const routes = [
},
];
const router = new VueRouter({
mode: "history",
base: config.base + "/",
const router = createRouter({
history: createWebHistory(`${config.base}/`),
routes,
});
new Vue({
router,
store,
render: (h) => h(App),
}).$mount("#app");
createApp(App)
.use(router)
.use(store)
.use(Autocomplete)
.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>
</template>
<script>
<script lang="ts">
import { mapGetters } from "vuex";
import Search from "../components/Search";
import LogContainer from "../components/LogContainer";
import Search from "../components/Search.vue";
import LogContainer from "../components/LogContainer.vue";
import { setTitle } from "@/composables/title";
export default {
props: ["id"],
@@ -17,19 +18,13 @@ export default {
LogContainer,
Search,
},
data() {
return {
title: "loading",
};
},
metaInfo() {
return {
title: this.title,
};
created() {
setTitle("loading");
},
mounted() {
if (this.allContainersById[this.id]) {
this.title = this.allContainersById[this.id].name;
setTitle(this.allContainersById[this.id].name);
}
},
computed: {
@@ -37,10 +32,10 @@ export default {
},
watch: {
id() {
this.title = this.allContainersById[this.id].name;
setTitle(this.allContainersById[this.id].name);
},
allContainersById() {
this.title = this.allContainersById[this.id].name;
setTitle(this.allContainersById[this.id].name);
},
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template></template>
<script>
import { mapActions, mapGetters, mapState } from "vuex";
<script lang="ts">
import { mapGetters } from "vuex";
export default {
props: [],
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 }}") {
config.version = "master";
config.base = "";
@@ -9,5 +11,4 @@ if (config.version == "{{ .Version }}") {
config.authorizationNeeded = config.authorizationNeeded === "true";
config.secured = config.secured === "true";
}
export default config;

View File

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