Vue3 (#1594)
* WIP vue3 * WIP vue3 * WIP vue3 * Migrates to vitejs * Fixes js tests and removes not needed modules * Fixes unmount * Updates to use css instead for space * Fixes tests and rebases one more time * Uses orgua * Fixes migrations bugs with oruga and fixes scroll * Fixes v-deep * Fixes icons to prod * Fixes icons to prod * Adds favicon back * Transitions some to composition api * Updates another component to comp api * Cleans defineProps * Updates log messages * Moves more to compose api * Cleans up styles and rewrites event source * Tries to fix DOMPurify * Removes postcss * WIP typescript * Improves importing * Converts all to ts * Converts main to ts * Makes changes for tsconfig * Moves more to ts * Adds typing to store * More typing * Updates to ts * Updates the rest to ts * Fixes computes * Fixes unmount * Adds cypress with custom base fixed * Fixes jest tests * Fixes golang tests * Adds gitignore for cypress * Removes int in favor of e2e with cypress * Tries to fix int tests again * Adds title * Updates e2e tests * Uses vue for isMobile * Removes app spec * Cleans up docker * Adds drop down for settings * Fixes bug with restart * Fixes scroll up bug * Adds tests for light mode
8
.babelrc
@@ -1,8 +1,4 @@
|
|||||||
{
|
{
|
||||||
"presets": [["env", { "modules": false }]],
|
"presets": ["@babel/preset-env"],
|
||||||
"env": {
|
"plugins": ["@babel/plugin-transform-runtime"]
|
||||||
"test": {
|
|
||||||
"presets": [["env", { "targets": { "node": "current" } }]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.cache
|
.cache
|
||||||
.idea
|
.idea
|
||||||
|
.github
|
||||||
dist
|
dist
|
||||||
.git
|
.git
|
||||||
static
|
e2e
|
||||||
integration
|
|
||||||
demo.gif
|
|
||||||
|
|||||||
4
.github/workflows/deploy.yml
vendored
@@ -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
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
20
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
138
assets/App.vue
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 { }
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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\\"><test>foo bar</test></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"><test>foo bar</test></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\\"><test>foo bar</test></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"><test>foo bar</test></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\\"><test>foo bar</test></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"><test>foo bar</test></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> <hi></hi></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> <hi></hi></span></li>
|
);
|
||||||
</ul>
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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("<mark>", "<mark>").replace("</mark>", "</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("<mark>", "<mark>").replace("</mark>", "</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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { mapGetters } from "vuex";
|
import { mapGetters } from "vuex";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
14
assets/components/__snapshots__/LogEventSource.spec.ts.snap
Normal 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>"
|
||||||
|
`;
|
||||||
@@ -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) {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
9
assets/composables/container.ts
Normal 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 };
|
||||||
|
}
|
||||||
3
assets/composables/mediaQuery.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { useMediaQuery } from "@vueuse/core";
|
||||||
|
|
||||||
|
export const isMobile = useMediaQuery("(max-width: 770px)");
|
||||||
12
assets/composables/title.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
videos
|
||||||
|
screenshots
|
||||||
|
__diff_output__
|
||||||
12
e2e/Dockerfile
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"DOZZLE_DEFAULT": "http://localhost:3000/"
|
||||||
|
}
|
||||||
3
e2e/cypress.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"fixturesFolder": false
|
||||||
|
}
|
||||||
25
e2e/cypress/integration/dozze_dark.spec.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/cypress/integration/dozzle_light.spec.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
e2e/cypress/plugins/index.js
Normal 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);
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
33
e2e/cypress/support/commands.js
Normal 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()));
|
||||||
|
});
|
||||||
20
e2e/cypress/support/index.js
Normal 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')
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -1 +0,0 @@
|
|||||||
node_modules
|
|
||||||
1
integration/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
__diff_output__
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
FROM amir20/docker-alpine-puppeteer:v1
|
|
||||||
|
|
||||||
COPY package*.json yarn.lock /app/
|
|
||||||
RUN yarn
|
|
||||||
|
|
||||||
COPY . /app/
|
|
||||||
|
|
||||||
CMD ["yarn", "test"]
|
|
||||||
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
const { toMatchImageSnapshot } = require("jest-image-snapshot");
|
|
||||||
|
|
||||||
expect.extend({ toMatchImageSnapshot });
|
|
||||||
|
|
||||||
jest.setTimeout(5000);
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"include": ["./assets/**/*"]
|
|
||||||
}
|
|
||||||
12
main.go
@@ -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)
|
||||||
|
|||||||
94
package.json
@@ -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
23
tsconfig.json
Normal 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
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||