mirror of
https://github.com/amir20/dozzle.git
synced 2025-12-31 10:07:22 +01:00
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
This commit is contained in:
@@ -4,37 +4,38 @@
|
||||
{{ state }}
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="stat.memoryUsage !== null">
|
||||
<span class="has-text-weight-light">mem</span>
|
||||
<span class="has-text-weight-light has-spacer">mem</span>
|
||||
<span class="has-text-weight-bold">
|
||||
{{ formatBytes(stat.memoryUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow" v-if="stat.cpu !== null">
|
||||
<span class="has-text-weight-light">load</span>
|
||||
<span class="has-text-weight-light has-spacer">load</span>
|
||||
<span class="has-text-weight-bold"> {{ stat.cpu }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
stat: Object,
|
||||
state: String,
|
||||
},
|
||||
name: "ContainerStat",
|
||||
methods: {
|
||||
formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
},
|
||||
},
|
||||
};
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
stat: Object,
|
||||
state: String,
|
||||
});
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
.has-spacer {
|
||||
&::after {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,13 +7,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
container: Object,
|
||||
},
|
||||
name: "ContainerTitle",
|
||||
};
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
container: Object,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<b-autocomplete
|
||||
<o-autocomplete
|
||||
ref="autocomplete"
|
||||
v-model="query"
|
||||
placeholder="Search containers using ⌘ + k, ⌃k"
|
||||
placeholder="Search containers using ⌘ + k or ctrl + k"
|
||||
field="name"
|
||||
open-on-focus
|
||||
keep-first
|
||||
@@ -11,11 +11,11 @@
|
||||
:data="results"
|
||||
@select="selected"
|
||||
>
|
||||
<template slot-scope="props">
|
||||
<template v-slot="props">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<span class="icon is-small" :class="props.option.state">
|
||||
<container-icon />
|
||||
<octicon-container-24 />
|
||||
</span>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
@@ -23,23 +23,19 @@
|
||||
</div>
|
||||
<div class="media-right">
|
||||
<span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column">
|
||||
<columns-icon />
|
||||
<cil-columns />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</b-autocomplete>
|
||||
</o-autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { mapState, mapActions } from "vuex";
|
||||
import fuzzysort from "fuzzysort";
|
||||
|
||||
import PastTime from "./PastTime";
|
||||
import ContainerIcon from "~icons/octicon/container-24";
|
||||
import ColumnsIcon from "~icons/cil/columns";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
maxResults: {
|
||||
@@ -53,11 +49,7 @@ export default {
|
||||
};
|
||||
},
|
||||
name: "FuzzySearchModal",
|
||||
components: {
|
||||
PastTime,
|
||||
ContainerIcon,
|
||||
ColumnsIcon,
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => this.$refs.autocomplete.focus());
|
||||
},
|
||||
@@ -110,6 +102,7 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
min-height: 400px;
|
||||
width: 580px;
|
||||
}
|
||||
|
||||
.running {
|
||||
@@ -126,7 +119,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep a.dropdown-item {
|
||||
:deep(a.dropdown-item) {
|
||||
padding-right: 1em;
|
||||
.media-right {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="observer" class="infinte-loader">
|
||||
<div ref="root" class="infinte-loader">
|
||||
<div class="spinner" v-show="isLoading">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
@@ -8,40 +8,34 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "InfiniteLoader",
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
onLoadMore: Function,
|
||||
enabled: Boolean,
|
||||
},
|
||||
mounted() {
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (this.onLoadMore && this.enabled) {
|
||||
const scrollingParent = this.$el.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
this.isLoading = true;
|
||||
await this.onLoadMore();
|
||||
this.isLoading = false;
|
||||
this.$nextTick(() => (scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight));
|
||||
}
|
||||
},
|
||||
{ threshholds: 1 }
|
||||
);
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
|
||||
intersectionObserver.observe(this.$refs.observer);
|
||||
const props = defineProps({
|
||||
onLoadMore: Function,
|
||||
enabled: Boolean,
|
||||
});
|
||||
|
||||
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
|
||||
},
|
||||
};
|
||||
const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
const observer = new IntersectionObserver(async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (props.onLoadMore && props.enabled) {
|
||||
const scrollingParent = root.value.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await props.onLoadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => observer.observe(root.value));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.infinte-loader {
|
||||
min-height: 1px;
|
||||
|
||||
@@ -1,62 +1,53 @@
|
||||
<template>
|
||||
<div class="toolbar mr-0 is-vcentered is-hidden-mobile">
|
||||
<div class="mr-0 toolbar is-vcentered is-hidden-mobile">
|
||||
<div class="is-flex">
|
||||
<b-tooltip type="is-dark" label="Clear">
|
||||
<a @click="onClearClicked" class="button is-small is-light is-inverted pl-1 pr-1" id="clear">
|
||||
<clear-icon />
|
||||
<o-tooltip type="is-dark" label="Clear">
|
||||
<a @click="onClearClicked" class="pl-1 pr-1 button is-small is-light is-inverted" id="clear">
|
||||
<octicon-trash-24 />
|
||||
</a>
|
||||
</b-tooltip>
|
||||
</o-tooltip>
|
||||
<div class="is-flex-grow-1"></div>
|
||||
<b-tooltip type="is-dark" label="Download">
|
||||
<o-tooltip type="is-dark" label="Download">
|
||||
<a
|
||||
class="button is-small is-light is-inverted pl-1 pr-1"
|
||||
class="pl-1 pr-1 button is-small is-light is-inverted"
|
||||
id="download"
|
||||
:href="`${base}/api/logs/download?id=${container.id}`"
|
||||
download
|
||||
>
|
||||
<download-icon />
|
||||
<octicon-download-24 />
|
||||
</a>
|
||||
</b-tooltip>
|
||||
</o-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import config from "../store/config";
|
||||
import hotkeys from "hotkeys-js";
|
||||
import DownloadIcon from "~icons/octicon/download-24";
|
||||
import ClearIcon from "~icons/octicon/trash-24";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
onClearClicked: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
container: {
|
||||
type: Object,
|
||||
},
|
||||
const props = defineProps({
|
||||
onClearClicked: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
name: "LogActionsToolbar",
|
||||
components: {
|
||||
DownloadIcon,
|
||||
ClearIcon,
|
||||
},
|
||||
computed: {
|
||||
base() {
|
||||
return config.base;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
hotkeys("shift+command+l, shift+ctrl+l", (event, handler) => {
|
||||
this.onClearClicked();
|
||||
event.preventDefault();
|
||||
});
|
||||
container: {
|
||||
type: Object,
|
||||
},
|
||||
});
|
||||
|
||||
const { base } = config;
|
||||
|
||||
const onHotkey = (event: Event) => {
|
||||
props.onClearClicked();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss" scoped>
|
||||
#download.button,
|
||||
#clear.button {
|
||||
.icon {
|
||||
|
||||
@@ -8,59 +8,62 @@
|
||||
<div class="column is-narrow is-paddingless">
|
||||
<container-stat :stat="container.stat" :state="container.state"></container-stat>
|
||||
</div>
|
||||
<div class="column is-narrow is-paddingless" v-if="closable">
|
||||
<div class="mr-2 column is-narrow is-paddingless" v-if="closable">
|
||||
<button class="delete is-medium" @click="$emit('close')"></button>
|
||||
</div>
|
||||
<!-- <div class="mr-2 column is-narrow is-paddingless">
|
||||
<o-dropdown aria-role="list" position="bottom-left">
|
||||
<template v-slot:trigger>
|
||||
<span class="btn">
|
||||
<span class="icon">
|
||||
<carbon-verflow-menu-vertical />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item aria-role="listitem"> Clear </o-dropdown-item>
|
||||
<o-dropdown-item aria-role="listitem">Download</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</div> -->
|
||||
</div>
|
||||
<log-actions-toolbar :container="container" :onClearClicked="onClearClicked"></log-actions-toolbar>
|
||||
</template>
|
||||
<template v-slot="{ setLoading }">
|
||||
<log-viewer-with-source ref="logViewer" :id="id" @loading-more="setLoading($event)"></log-viewer-with-source>
|
||||
<log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)"></log-viewer-with-source>
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LogViewerWithSource from "./LogViewerWithSource";
|
||||
import LogActionsToolbar from "./LogActionsToolbar";
|
||||
import ScrollableView from "./ScrollableView";
|
||||
import ContainerTitle from "./ContainerTitle";
|
||||
import ContainerStat from "./ContainerStat";
|
||||
import containerMixin from "./mixins/container";
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs } from "vue";
|
||||
import useContainer from "../composables/container";
|
||||
|
||||
export default {
|
||||
mixins: [containerMixin],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: "LogContainer",
|
||||
components: {
|
||||
LogViewerWithSource,
|
||||
LogActionsToolbar,
|
||||
ScrollableView,
|
||||
ContainerTitle,
|
||||
ContainerStat,
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
methods: {
|
||||
onClearClicked() {
|
||||
this.$refs.logViewer.clear();
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { id } = toRefs(props);
|
||||
const { container } = useContainer(id);
|
||||
|
||||
const viewer = ref<HTMLElement>();
|
||||
|
||||
function onClearClicked() {
|
||||
viewer.value?.clear();
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
button.delete {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createStore } from "vuex";
|
||||
// @ts-ignore
|
||||
import EventSource, { sources } from "eventsourcemock";
|
||||
import debounce from "lodash.debounce";
|
||||
import EventSource from "eventsourcemock";
|
||||
import { sources } from "eventsourcemock";
|
||||
import { shallowMount, mount, createLocalVue } from "@vue/test-utils";
|
||||
import Vuex from "vuex";
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { mocked } from "ts-jest/utils";
|
||||
|
||||
jest.mock("lodash.debounce", () =>
|
||||
jest.fn((fn) => {
|
||||
@@ -13,69 +14,68 @@ jest.mock("lodash.debounce", () =>
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock("../store/config.js", () => ({ base: "" }));
|
||||
jest.mock("../store/config.ts", () => ({ base: "" }));
|
||||
|
||||
describe("<LogEventSource />", () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
global.EventSource = EventSource;
|
||||
window.scrollTo = jest.fn();
|
||||
const observe = jest.fn();
|
||||
const disconnect = jest.fn();
|
||||
global.IntersectionObserver = jest.fn(() => ({
|
||||
observe,
|
||||
disconnect,
|
||||
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
debounce.mockClear();
|
||||
|
||||
mocked(debounce).mockClear();
|
||||
});
|
||||
|
||||
function createLogEventSource({ hourStyle = "auto", searchFilter = null } = {}) {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
localVue.component("log-viewer", LogViewer);
|
||||
|
||||
const state = { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } };
|
||||
const getters = {
|
||||
allContainersById() {
|
||||
return {
|
||||
abc: { state: "running" },
|
||||
};
|
||||
function createLogEventSource({
|
||||
hourStyle = "auto",
|
||||
searchFilter = null,
|
||||
}: { hourStyle?: string; searchFilter?: string | null } = {}) {
|
||||
const store = createStore({
|
||||
state: { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } },
|
||||
getters: {
|
||||
allContainersById() {
|
||||
return {
|
||||
abc: { state: "running" },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state,
|
||||
getters,
|
||||
});
|
||||
|
||||
return mount(LogEventSource, {
|
||||
localVue,
|
||||
store,
|
||||
scopedSlots: {
|
||||
global: {
|
||||
plugins: [store],
|
||||
components: {
|
||||
LogViewer,
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: `
|
||||
<log-viewer :messages="props.messages"></log-viewer>
|
||||
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
|
||||
`,
|
||||
},
|
||||
propsData: { id: "abc" },
|
||||
props: { id: "abc" },
|
||||
});
|
||||
}
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should connect to EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
|
||||
wrapper.destroy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("should close EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
wrapper.destroy();
|
||||
wrapper.unmount();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("<LogEventSource />", () => {
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
});
|
||||
const [message, _] = wrapper.findComponent(LogViewer).vm.messages;
|
||||
const [message, _] = wrapper.getComponent(LogViewer).vm.messages;
|
||||
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
@@ -138,8 +138,10 @@ describe("<LogEventSource />", () => {
|
||||
describe("render html correctly", () => {
|
||||
const RealDate = Date;
|
||||
beforeAll(() => {
|
||||
// @ts-ignore
|
||||
global.Date = class extends RealDate {
|
||||
constructor(arg) {
|
||||
constructor(arg: any | number) {
|
||||
super(arg);
|
||||
if (arg) {
|
||||
return new RealDate(arg);
|
||||
} else {
|
||||
@@ -158,11 +160,9 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">"This is a message."</span></li>
|
||||
</ul>
|
||||
`);
|
||||
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
|
||||
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">\\"This is a message.\\"</span></li></ul>"`
|
||||
);
|
||||
});
|
||||
|
||||
test("should render messages with color", async () => {
|
||||
@@ -173,11 +173,9 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text"><span style="color:#000">black<span style="color:#AAA">white</span></span></span></li>
|
||||
</ul>
|
||||
`);
|
||||
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
|
||||
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span></li></ul>"`
|
||||
);
|
||||
});
|
||||
|
||||
test("should render messages with html entities", async () => {
|
||||
@@ -188,11 +186,9 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text"><test>foo bar</test></span></li>
|
||||
</ul>
|
||||
`);
|
||||
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
|
||||
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\"><test>foo bar</test></span></li></ul>"`
|
||||
);
|
||||
});
|
||||
|
||||
test("should render dates with 12 hour style", async () => {
|
||||
@@ -203,11 +199,9 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 11:55:42 PM</time></span> <span class="text"><test>foo bar</test></span></li>
|
||||
</ul>
|
||||
`);
|
||||
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
|
||||
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\">today at 11:55:42 PM</time></span><span class=\\"text\\"><test>foo bar</test></span></li></ul>"`
|
||||
);
|
||||
});
|
||||
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
@@ -218,11 +212,9 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 23:55:42</time></span> <span class="text"><test>foo bar</test></span></li>
|
||||
</ul>
|
||||
`);
|
||||
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
|
||||
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\">today at 23:55:42</time></span><span class=\\"text\\"><test>foo bar</test></span></li></ul>"`
|
||||
);
|
||||
});
|
||||
|
||||
test("should render messages with filter", async () => {
|
||||
@@ -236,11 +228,9 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">This is a <mark>test</mark> <hi></hi></span></li>
|
||||
</ul>
|
||||
`);
|
||||
expect(wrapper.find("ul.events").html()).toMatchInlineSnapshot(
|
||||
`"<ul class=\\"events medium\\"><li><span class=\\"date\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\">today at 10:55:42 AM</time></span><span class=\\"text\\">This is a <mark>test</mark> <hi></hi></span></li></ul>"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,121 +5,139 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, ref, watch, onUnmounted } from "vue";
|
||||
import debounce from "lodash.debounce";
|
||||
import InfiniteLoader from "./InfiniteLoader";
|
||||
|
||||
import InfiniteLoader from "./InfiniteLoader.vue";
|
||||
|
||||
import config from "../store/config";
|
||||
import containerMixin from "./mixins/container";
|
||||
import useContainer from "../composables/container";
|
||||
|
||||
export default {
|
||||
props: ["id"],
|
||||
mixins: [containerMixin],
|
||||
name: "LogEventSource",
|
||||
components: {
|
||||
InfiniteLoader,
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
buffer: [],
|
||||
es: null,
|
||||
lastEventId: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.flushBuffer = debounce(this.flushNow, 250, { maxWait: 1000 });
|
||||
this.loadLogs();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.es.close();
|
||||
},
|
||||
methods: {
|
||||
loadLogs() {
|
||||
this.reset();
|
||||
this.connect();
|
||||
},
|
||||
onContainerStopped() {
|
||||
this.es.close();
|
||||
this.buffer.push({ event: "container-stopped", message: "Container stopped", date: new Date(), key: new Date() });
|
||||
this.flushBuffer();
|
||||
this.flushBuffer.flush();
|
||||
},
|
||||
onMessage(e) {
|
||||
this.lastEventId = e.lastEventId;
|
||||
this.buffer.push(this.parseMessage(e.data));
|
||||
this.flushBuffer();
|
||||
},
|
||||
onContainerStateChange(newValue, oldValue) {
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
this.buffer.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
key: new Date(),
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
},
|
||||
connect() {
|
||||
this.es = new EventSource(`${config.base}/api/logs/stream?id=${this.id}&lastEventId=${this.lastEventId ?? ""}`);
|
||||
this.es.addEventListener("container-stopped", (e) => this.onContainerStopped());
|
||||
this.es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
this.es.onmessage = (e) => this.onMessage(e);
|
||||
},
|
||||
flushNow() {
|
||||
this.messages.push(...this.buffer);
|
||||
this.buffer = [];
|
||||
},
|
||||
clear() {
|
||||
this.messages = [];
|
||||
},
|
||||
reset() {
|
||||
if (this.es) {
|
||||
this.es.close();
|
||||
}
|
||||
this.flushBuffer.cancel();
|
||||
this.es = null;
|
||||
this.messages = [];
|
||||
this.buffer = [];
|
||||
this.lastEventId = null;
|
||||
},
|
||||
async loadOlderLogs() {
|
||||
if (this.messages.length < 300) return;
|
||||
});
|
||||
|
||||
this.$emit("loading-more", true);
|
||||
const to = this.messages[0].date;
|
||||
const last = this.messages[299].date;
|
||||
const delta = to - last;
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${this.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => this.parseMessage(line));
|
||||
this.messages.unshift(...newMessages);
|
||||
}
|
||||
this.$emit("loading-more", false);
|
||||
},
|
||||
parseMessage(data) {
|
||||
let i = data.indexOf(" ");
|
||||
if (i == -1) {
|
||||
i = data.length;
|
||||
}
|
||||
const key = data.substring(0, i);
|
||||
const date = new Date(key);
|
||||
const message = data.substring(i + 1);
|
||||
return { key, date, message };
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
id(newValue, oldValue) {
|
||||
if (oldValue !== newValue) {
|
||||
this.loadLogs();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
const { id } = toRefs(props);
|
||||
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
|
||||
interface LogEntry {
|
||||
date: Date;
|
||||
message: String;
|
||||
key: String;
|
||||
event?: String;
|
||||
}
|
||||
|
||||
const messages = ref<LogEntry[]>([]);
|
||||
const buffer = ref<LogEntry[]>([]);
|
||||
|
||||
function flushNow() {
|
||||
messages.value.push(...buffer.value);
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let lastEventId = "";
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
es?.close();
|
||||
|
||||
if (clear) {
|
||||
flushBuffer.cancel();
|
||||
messages.value = [];
|
||||
buffer.value = [];
|
||||
lastEventId = "";
|
||||
}
|
||||
|
||||
es = new EventSource(`${config.base}/api/logs/stream?id=${props.id}&lastEventId=${lastEventId}`);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.value.push({
|
||||
event: "container-stopped",
|
||||
message: "Container stopped",
|
||||
date: new Date(),
|
||||
key: new Date().toString(),
|
||||
});
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
es.onmessage = (e) => {
|
||||
lastEventId = e.lastEventId;
|
||||
if (e.data) {
|
||||
buffer.value.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (messages.value.length < 300) return;
|
||||
|
||||
emit("loading-more", true);
|
||||
const to = messages.value[0].date;
|
||||
const last = messages.value[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value.unshift(...newMessages);
|
||||
}
|
||||
emit("loading-more", false);
|
||||
}
|
||||
|
||||
function parseMessage(data: String): LogEntry {
|
||||
let i = data.indexOf(" ");
|
||||
if (i == -1) {
|
||||
i = data.length;
|
||||
}
|
||||
const key = data.substring(0, i);
|
||||
const date = new Date(key);
|
||||
const message = data.substring(i + 1);
|
||||
return { key, date, message };
|
||||
}
|
||||
|
||||
const { container } = useContainer(id);
|
||||
|
||||
watch(
|
||||
() => container.value.state,
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.value.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
key: new Date().toString(),
|
||||
});
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
watch(id, () => connect());
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,65 +1,54 @@
|
||||
<template>
|
||||
<ul class="events" :class="settings.size">
|
||||
<li v-for="item in filtered" :key="item.key" :data-event="item.event">
|
||||
<span class="date" v-if="settings.showTimestamp"><relative-time :date="item.date"></relative-time></span>
|
||||
<span class="date" v-if="settings.showTimestamp"> <relative-time :date="item.date"></relative-time></span>
|
||||
<span class="text" v-html="colorize(item.message)"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import RelativeTime from "./RelativeTime.vue";
|
||||
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import DOMPurify from "dompurify";
|
||||
import RelativeTime from "./RelativeTime";
|
||||
|
||||
const props = defineProps({
|
||||
messages: Array,
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
|
||||
|
||||
if (window.trustedTypes && trustedTypes.createPolicy) {
|
||||
trustedTypes.createPolicy("default", {
|
||||
createHTML: (string, sink) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: true }),
|
||||
});
|
||||
function colorize(value) {
|
||||
return ansiConvertor.toHtml(value).replace("<mark>", "<mark>").replace("</mark>", "</mark>");
|
||||
}
|
||||
|
||||
export default {
|
||||
props: ["messages"],
|
||||
name: "LogViewer",
|
||||
components: { RelativeTime },
|
||||
data() {
|
||||
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 {
|
||||
const regex = isSmartCase ? new RegExp(searchFilter, "i") : new RegExp(searchFilter);
|
||||
return messages
|
||||
.filter((d) => d.message.match(regex))
|
||||
.map((d) => ({
|
||||
...d,
|
||||
message: d.message.replace(regex, "<mark>$&</mark>"),
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
console.info(`Ignoring SytaxError from search.`, e);
|
||||
return messages;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const settings = computed(() => store.state.settings);
|
||||
const searchFilter = computed(() => store.state.searchFilter);
|
||||
const filtered = computed(() => {
|
||||
if (searchFilter && searchFilter.value) {
|
||||
const isSmartCase = searchFilter.value === searchFilter.value.toLowerCase();
|
||||
try {
|
||||
const regex = isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value);
|
||||
return props.messages
|
||||
.filter((d) => d.message.match(regex))
|
||||
.map((d) => ({
|
||||
...d,
|
||||
message: d.message.replace(regex, "<mark>$&</mark>"),
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
console.info(`Ignoring SytaxError from search.`, e);
|
||||
return props.messages;
|
||||
}
|
||||
return messages;
|
||||
},
|
||||
},
|
||||
};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return props.messages;
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.events {
|
||||
@@ -108,9 +97,12 @@ export default {
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
&::before {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep mark {
|
||||
:deep(mark) {
|
||||
border-radius: 2px;
|
||||
background-color: var(--secondary-color);
|
||||
animation: pops 200ms ease-out;
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<template>
|
||||
<log-event-source ref="logEventSource" :id="id" v-slot="eventSource" @loading-more="$emit('loading-more', $event)">
|
||||
<log-event-source ref="source" :id="id" v-slot="eventSource" @loading-more="emit('loading-more', $event)">
|
||||
<log-viewer :messages="eventSource.messages"></log-viewer>
|
||||
</log-event-source>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LogEventSource from "./LogEventSource";
|
||||
import LogViewer from "./LogViewer";
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
props: ["id"],
|
||||
name: "LogViewerWithSource",
|
||||
components: {
|
||||
LogEventSource,
|
||||
LogViewer,
|
||||
},
|
||||
methods: {
|
||||
clear() {
|
||||
this.$refs.logEventSource.clear();
|
||||
},
|
||||
},
|
||||
};
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
|
||||
const source = ref<HTMLElement>();
|
||||
function clear() {
|
||||
source.value?.clear();
|
||||
}
|
||||
defineExpose({
|
||||
clear,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<time :datetime="date.toISOString()">{{ text }}</time>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import formatDistance from "date-fns/formatDistance";
|
||||
|
||||
export default {
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: "",
|
||||
text: "" as string,
|
||||
interval: null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()">{{ date | relativeTime(locale) }}</time>
|
||||
<time :datetime="date.toISOString()">{{ relativeTime(date, locale) }}</time>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { mapState } from "vuex";
|
||||
import { formatRelative } from "date-fns";
|
||||
import enGB from "date-fns/locale/en-GB";
|
||||
@@ -27,7 +27,6 @@ export default {
|
||||
},
|
||||
name: "RelativeTime",
|
||||
components: {},
|
||||
|
||||
computed: {
|
||||
...mapState(["settings"]),
|
||||
locale() {
|
||||
@@ -41,7 +40,7 @@ export default {
|
||||
};
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
methods: {
|
||||
relativeTime(date, locale) {
|
||||
return formatRelative(date, new Date(), { locale });
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { mapGetters } from "vuex";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
@@ -45,7 +45,9 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.attachEvents();
|
||||
this.$once("hook:beforeDestroy", this.detachEvents);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.detachEvents();
|
||||
},
|
||||
watch: {
|
||||
activeContainers() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<header v-if="$slots.header">
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
<main ref="content" :data-scrolling="scrollable">
|
||||
<main ref="content" :data-scrolling="scrollable ? true : undefined">
|
||||
<div class="is-scrollbar-progress is-hidden-mobile">
|
||||
<scroll-progress v-show="paused" :indeterminate="loading" :auto-hide="!loading"></scroll-progress>
|
||||
</div>
|
||||
@@ -13,23 +13,15 @@
|
||||
|
||||
<div class="is-scrollbar-notification">
|
||||
<transition name="fade">
|
||||
<button
|
||||
class="button pl-1 pr-1"
|
||||
:class="hasMore ? 'has-more' : ''"
|
||||
@click="scrollToBottom('instant')"
|
||||
v-show="paused"
|
||||
>
|
||||
<chevron-double-down-icon />
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
|
||||
<mdi-light-chevron-double-down />
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScrollProgress from "./ScrollProgress";
|
||||
import ChevronDoubleDownIcon from "~icons/mdi-light/chevron-double-down";
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: {
|
||||
scrollable: {
|
||||
@@ -37,21 +29,20 @@ export default {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ScrollProgress,
|
||||
ChevronDoubleDownIcon,
|
||||
},
|
||||
|
||||
name: "ScrollableView",
|
||||
data() {
|
||||
return {
|
||||
paused: false,
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
mutationObserver: null,
|
||||
intersectionObserver: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const { content } = this.$refs;
|
||||
const mutationObserver = new MutationObserver((e) => {
|
||||
this.mutationObserver = new MutationObserver((e) => {
|
||||
if (!this.paused) {
|
||||
this.scrollToBottom("instant");
|
||||
} else {
|
||||
@@ -63,17 +54,18 @@ export default {
|
||||
}
|
||||
}
|
||||
});
|
||||
mutationObserver.observe(content, { childList: true, subtree: true });
|
||||
this.$once("hook:beforeDestroy", () => mutationObserver.disconnect());
|
||||
this.mutationObserver.observe(content, { childList: true, subtree: true });
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => (this.paused = entries[0].intersectionRatio == 0),
|
||||
{ threshholds: [0, 1], rootMargin: "80px 0px" }
|
||||
);
|
||||
intersectionObserver.observe(this.$refs.scrollObserver);
|
||||
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
|
||||
this.intersectionObserver.observe(this.$refs.scrollObserver);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.mutationObserver.disconnect();
|
||||
this.intersectionObserver.disconnect();
|
||||
},
|
||||
|
||||
methods: {
|
||||
scrollToBottom(behavior = "instant") {
|
||||
this.$refs.scrollObserver.scrollIntoView({ behavior });
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@keyup.esc="resetSearch()"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<search-icon />
|
||||
<mdi-light-magnify />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -21,17 +21,13 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { mapActions, mapState } from "vuex";
|
||||
import hotkeys from "hotkeys-js";
|
||||
import SearchIcon from "~icons/mdi-light/magnify";
|
||||
|
||||
export default {
|
||||
props: [],
|
||||
name: "Search",
|
||||
components: {
|
||||
SearchIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSearch: false,
|
||||
@@ -47,7 +43,7 @@ export default {
|
||||
this.resetSearch();
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
this.updateSearchFilter("");
|
||||
hotkeys.unbind("command+f, ctrl+f, esc");
|
||||
},
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right px-1">
|
||||
<button
|
||||
class="button is-small is-rounded is-settings-control pl-1 pr-1"
|
||||
class="button is-rounded is-settings-control"
|
||||
@click="$emit('search')"
|
||||
title="Search containers (⌘ + k, ⌃k)"
|
||||
>
|
||||
<search-icon />
|
||||
<span class="icon">
|
||||
<mdi-light-magnify />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right px-0">
|
||||
<router-link
|
||||
:to="{ name: 'settings' }"
|
||||
active-class="is-active"
|
||||
class="button is-small is-rounded is-settings-control pl-1 pr-1"
|
||||
>
|
||||
<settings-icon />
|
||||
<router-link :to="{ name: 'settings' }" active-class="is-active" class="button is-rounded is-settings-control">
|
||||
<span class="icon">
|
||||
<mdi-light-cog />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
v-show="!activeContainersById[item.id]"
|
||||
title="Pin as column"
|
||||
>
|
||||
<columns-icon />
|
||||
<cil-columns />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,21 +56,13 @@
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters, mapState } from "vuex";
|
||||
|
||||
import SearchIcon from "~icons/mdi-light/magnify";
|
||||
import SettingsIcon from "~icons/mdi-light/cog";
|
||||
import ColumnsIcon from "~icons/cil/columns";
|
||||
<script lang="ts">
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
props: [],
|
||||
name: "SideMenu",
|
||||
components: {
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
ColumnsIcon,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
|
||||
@@ -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
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) {},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user