Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f0e2127a | ||
|
|
163b1c7e28 | ||
|
|
db01579f04 | ||
|
|
be7c154d6b | ||
|
|
b1bc706de2 | ||
|
|
40f5cb1301 | ||
|
|
cedfbee983 | ||
|
|
c835f51cc4 | ||
|
|
5ab06d5906 | ||
|
|
d44316fa9c | ||
|
|
6ef3da9abd | ||
|
|
752495ed6f | ||
|
|
8f895e40bc | ||
|
|
cd9ddcf427 | ||
|
|
bbc7794006 | ||
|
|
7dc37f130c | ||
|
|
0711bc1c76 | ||
|
|
0aa24386b2 | ||
|
|
ca35b93671 | ||
|
|
a6220e4d38 | ||
|
|
4ed64a7cce | ||
|
|
0f27e11084 | ||
|
|
85eafc9c40 | ||
|
|
332cc384ea | ||
|
|
72fd31f85b | ||
|
|
a0ce370e9e | ||
|
|
e823904865 | ||
|
|
22bbfe1592 | ||
|
|
770e1818f0 | ||
|
|
d6fab75f8f | ||
|
|
17c18c156e | ||
|
|
5eca19840e | ||
|
|
b1d7b8ba55 | ||
|
|
e2ee430bbd | ||
|
|
0755a71dc2 | ||
|
|
60758db9c8 | ||
|
|
7b96196904 | ||
|
|
efcfa0e375 | ||
|
|
66f9204ae6 | ||
|
|
73c023ce22 | ||
|
|
261517ac3f | ||
|
|
2e0a546aa2 | ||
|
|
72ed7b50ba | ||
|
|
486bcec363 | ||
|
|
3db0ad42fe | ||
|
|
c1a75e21ba | ||
|
|
96c5e24501 | ||
|
|
c1a16fd76e | ||
|
|
42fab58c9f | ||
|
|
400cef767f | ||
|
|
84ae558467 | ||
|
|
0ebc9c562a | ||
|
|
f67664470f | ||
|
|
1f811da273 | ||
|
|
fdfc9fceba | ||
|
|
5b5b741b68 | ||
|
|
18c88d0e85 | ||
|
|
1603a19538 | ||
|
|
5cffa287d5 | ||
|
|
93f57b6e90 | ||
|
|
2346f6a0eb | ||
|
|
f95317ac1d | ||
|
|
157a612f34 | ||
|
|
42c890ad50 | ||
|
|
48638a18f2 | ||
|
|
fae0640bba | ||
|
|
23b37bb912 | ||
|
|
07135fea91 | ||
|
|
1a3c394fe4 | ||
|
|
38ec37ed19 | ||
|
|
738ae98f2f | ||
|
|
99d1e83882 | ||
|
|
d71be7e239 | ||
|
|
c0b9325efb | ||
|
|
538fe6f158 | ||
|
|
965d1a52b1 | ||
|
|
6be73692ba | ||
|
|
f694c168d3 | ||
|
|
b7c24dcafa | ||
|
|
67a1c4a207 | ||
|
|
b188f689ea | ||
|
|
6f354c500c | ||
|
|
8ba5d36801 | ||
|
|
6822a95cc9 | ||
|
|
fcc4647379 | ||
|
|
0305ee9502 | ||
|
|
0e527e8ec0 | ||
|
|
91b2dc36c2 | ||
|
|
3dc7949a86 | ||
|
|
5cf625ef65 | ||
|
|
8d5deff2ed | ||
|
|
9f6df9a25a | ||
|
|
a34733bc88 | ||
|
|
7cf02f40e6 | ||
|
|
9d2e87f0f3 | ||
|
|
034984a784 | ||
|
|
d11fcdfec5 | ||
|
|
af08b5cd1b | ||
|
|
c666917740 | ||
|
|
059c3361ca | ||
|
|
f18fdcec8c | ||
|
|
18d6aa2a34 | ||
|
|
db4643d271 | ||
|
|
9d5b6faf03 | ||
|
|
72e0a1ba2d | ||
|
|
35d4f3c8d3 | ||
|
|
6dfafbf531 | ||
|
|
11e7717519 | ||
|
|
a4539399d2 | ||
|
|
d14be81f18 | ||
|
|
44c4366bba | ||
|
|
20b115f99f | ||
|
|
e99ba5b6ae | ||
|
|
12d32ee8f2 | ||
|
|
0f423e8b60 |
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.1
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
@@ -39,9 +39,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Build images
|
||||
run: docker-compose -f e2e/docker-compose.yml build
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
|
||||
- name: Run tests
|
||||
run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
buildx:
|
||||
needs: [go-test, npm-test, int-test]
|
||||
name: Release
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.1
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
|
||||
4
.github/workflows/dev.yml
vendored
4
.github/workflows/dev.yml
vendored
@@ -27,10 +27,10 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.1
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
@@ -43,8 +43,12 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Build images
|
||||
run: docker-compose -f e2e/docker-compose.yml build
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
|
||||
- name: Set commit message for push
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
@@ -58,4 +62,4 @@ jobs:
|
||||
git log -1 --pretty=%B ${{github.event.pull_request.head.sha}} >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Run tests
|
||||
run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ static
|
||||
dozzle
|
||||
coverage
|
||||
.pnpm-debug.log
|
||||
.vscode
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# Build assets
|
||||
FROM node:18-alpine as node
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine as node
|
||||
|
||||
RUN apk add --no-cache git openssh make g++ util-linux python3 && npm install -g pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
|
||||
WORKDIR /build
|
||||
@@ -19,9 +19,9 @@ COPY assets ./assets
|
||||
# Install dependencies
|
||||
RUN pnpm install -r --offline --prod --ignore-scripts && pnpm build
|
||||
|
||||
FROM golang:1.18.2-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.19.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates && mkdir /dozzle
|
||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
|
||||
|
||||
WORKDIR /dozzle
|
||||
|
||||
@@ -34,15 +34,18 @@ COPY --from=node /build/dist ./dist
|
||||
|
||||
# Copy all other files
|
||||
COPY analytics ./analytics
|
||||
COPY healthcheck ./healthcheck
|
||||
COPY docker ./docker
|
||||
COPY web ./web
|
||||
COPY main.go ./
|
||||
|
||||
# Args
|
||||
ARG TAG=dev
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Build binary
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG" -o dozzle
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG" -o dozzle
|
||||
|
||||
|
||||
FROM scratch
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -58,6 +58,30 @@ Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo
|
||||
ports:
|
||||
- 9999:8080
|
||||
|
||||
|
||||
### Enabling health check
|
||||
|
||||
Dozzle doesn't enable healthcheck by default as it adds extra CPU usage. `healthcheck` can be enabled manually.
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
dozzle:
|
||||
container_name: dozzle
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
DOZZLE_LEVEL: trace
|
||||
healthcheck:
|
||||
test: [ "CMD", "/dozzle", "healthcheck" ]
|
||||
interval: 3s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
|
||||
#### Security
|
||||
|
||||
You can control the device Dozzle binds to by passing `--addr` parameter. For example,
|
||||
@@ -105,11 +129,8 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
|
||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||
| `--username` | `DOZZLE_USERNAME` | `""` |
|
||||
| `--password` | `DOZZLE_PASSWORD` | `""` |
|
||||
| `--key` | `DOZZLE_KEY` | `""` |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
|
||||
|
||||
Note: When using username and password `DOZZLE_KEY` is required for session management.
|
||||
|
||||
## Troubleshooting and FAQs
|
||||
|
||||
<details>
|
||||
|
||||
@@ -69,20 +69,6 @@ const containerStore = useContainerStore();
|
||||
const { activeContainers, visibleContainers } = storeToRefs(containerStore);
|
||||
|
||||
onMounted(() => {
|
||||
if (smallerScrollbars.value) {
|
||||
document.documentElement.classList.add("has-custom-scrollbars");
|
||||
}
|
||||
switch (lightTheme.value) {
|
||||
case "dark":
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
break;
|
||||
case "light":
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
break;
|
||||
default:
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
|
||||
hotkeys("command+k, ctrl+k", (event, handler) => {
|
||||
event.preventDefault();
|
||||
showFuzzySearch();
|
||||
@@ -121,7 +107,7 @@ function showFuzzySearch() {
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
function onResized(e) {
|
||||
function onResized(e: any) {
|
||||
if (e.length == 2) {
|
||||
menuWidth.value = e[0].size;
|
||||
}
|
||||
|
||||
8
assets/components.d.ts
vendored
8
assets/components.d.ts
vendored
@@ -1,8 +1,10 @@
|
||||
// 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
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default']
|
||||
@@ -11,8 +13,10 @@ declare module '@vue/runtime-core' {
|
||||
ContainerStat: typeof import('./components/ContainerStat.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerTitle.vue')['default']
|
||||
DropdownMenu: typeof import('./components/DropdownMenu.vue')['default']
|
||||
FieldList: typeof import('./components/FieldList.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
JSONPayload: typeof import('./components/JSONPayload.vue')['default']
|
||||
LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default']
|
||||
LogContainer: typeof import('./components/LogContainer.vue')['default']
|
||||
LogEventSource: typeof import('./components/LogEventSource.vue')['default']
|
||||
@@ -38,5 +42,3 @@ declare module '@vue/runtime-core' {
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
<template>
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile">
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile" v-if="container.stat">
|
||||
<div class="column is-narrow has-text-weight-bold">
|
||||
{{ state }}
|
||||
{{ container.state }}
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="stat.memoryUsage !== null">
|
||||
<div class="column is-narrow" v-if="container.stat.memoryUsage !== null">
|
||||
<span class="has-text-weight-light has-spacer">mem</span>
|
||||
<span class="has-text-weight-bold">
|
||||
{{ formatBytes(stat.memoryUsage) }}
|
||||
{{ formatBytes(container.stat.memoryUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow" v-if="stat.cpu !== null">
|
||||
<div class="column is-narrow" v-if="container.stat.cpu !== null">
|
||||
<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"> {{ container.stat.cpu }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ContainerStat } from "@/types/Container";
|
||||
import { PropType } from "vue";
|
||||
import { Container } from "@/types/Container";
|
||||
import { ComputedRef, inject } from "vue";
|
||||
import { formatBytes } from "@/utils";
|
||||
|
||||
defineProps({
|
||||
stat: {
|
||||
type: Object as PropType<ContainerStat>,
|
||||
required: true,
|
||||
},
|
||||
state: String,
|
||||
});
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -9,13 +9,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/types/Container";
|
||||
import { PropType } from "vue";
|
||||
defineProps({
|
||||
container: {
|
||||
type: Object as PropType<Container>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import { inject, ComputedRef } from "vue";
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
84
assets/components/FieldList.vue
Normal file
84
assets/components/FieldList.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<ul v-if="expanded" ref="root">
|
||||
<li v-for="(value, name) in fields">
|
||||
<template v-if="isObject(value)">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<field-list
|
||||
:fields="value"
|
||||
:parent-key="parentKey.concat(name)"
|
||||
:visible-keys="visibleKeys"
|
||||
expanded
|
||||
></field-list>
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span>[
|
||||
<span class="has-text-weight-bold" v-for="(item, index) in value">
|
||||
{{ item }}
|
||||
<span v-if="index !== value.length - 1">,</span>
|
||||
</span>
|
||||
]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span><span class="has-text-weight-bold">{{ value }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { arrayEquals, isObject } from "@/utils";
|
||||
import { nextTick, PropType, ref, toRaw } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
required: true,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
parentKey: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: [],
|
||||
},
|
||||
visibleKeys: {
|
||||
type: Array as PropType<string[][]>,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
async function toggleField(field: string) {
|
||||
const index = fieldIndex(field);
|
||||
|
||||
if (index > -1) {
|
||||
props.visibleKeys.splice(index, 1);
|
||||
} else {
|
||||
props.visibleKeys.push(props.parentKey.concat(field));
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
root.value?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
|
||||
function hasField(field: string) {
|
||||
return fieldIndex(field) > -1;
|
||||
}
|
||||
|
||||
function fieldIndex(field: string) {
|
||||
const path = props.parentKey.concat(field);
|
||||
return props.visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@ 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 scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await props.onLoadMore();
|
||||
@@ -32,7 +32,7 @@ const observer = new IntersectionObserver(async (entries) => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => observer.observe(root.value));
|
||||
onMounted(() => observer.observe(root.value!));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
|
||||
|
||||
55
assets/components/JSONPayload.vue
Normal file
55
assets/components/JSONPayload.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<ul class="fields" @click="expanded = !expanded">
|
||||
<li v-for="(value, name) in logEntry.message">
|
||||
<template v-if="value">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<span class="has-text-weight-bold" v-html="markSearch(value)"></span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<field-list :fields="logEntry.unfilteredPayload" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
|
||||
import { PropType, ref } from "vue";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
defineProps({
|
||||
logEntry: {
|
||||
type: Object as PropType<VisibleLogEntry>,
|
||||
required: true,
|
||||
},
|
||||
visibleKeys: {
|
||||
type: Array as PropType<string[][]>,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fields {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
&::after {
|
||||
content: "expand json";
|
||||
color: var(--secondary-color);
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -41,11 +41,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, PropType } from "vue";
|
||||
import { inject, onMounted, onUnmounted, PropType, ComputedRef } from "vue";
|
||||
import hotkeys from "hotkeys-js";
|
||||
import config from "@/stores/config";
|
||||
import { Container } from "@/types/Container";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { Container } from "@/types/Container";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
|
||||
@@ -56,10 +56,6 @@ const props = defineProps({
|
||||
type: Function as PropType<(e: Event) => void>,
|
||||
default: (e: Event) => {},
|
||||
},
|
||||
container: {
|
||||
type: Object as () => Container,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onHotkey = (event: Event) => {
|
||||
@@ -67,6 +63,8 @@ const onHotkey = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
|
||||
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
</script>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile">
|
||||
<div class="column is-clipped is-paddingless">
|
||||
<container-title :container="container" @close="$emit('close')" />
|
||||
<container-title @close="$emit('close')" />
|
||||
</div>
|
||||
<div class="column is-narrow is-paddingless">
|
||||
<container-stat :stat="container.stat" :state="container.state" v-if="container.stat" />
|
||||
<container-stat v-if="container.stat" />
|
||||
</div>
|
||||
|
||||
<div class="mr-2 column is-narrow is-paddingless">
|
||||
<log-actions-toolbar :container="container" :onClearClicked="onClearClicked" />
|
||||
<log-actions-toolbar :onClearClicked="onClearClicked" />
|
||||
</div>
|
||||
<div class="mr-2 column is-narrow is-paddingless" v-if="closable">
|
||||
<button class="delete is-medium" @click="emit('close')"></button>
|
||||
@@ -18,13 +18,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)" />
|
||||
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs } from "vue";
|
||||
import { provide, ref, toRefs } from "vue";
|
||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
|
||||
@@ -54,6 +54,8 @@ const store = useContainerStore();
|
||||
|
||||
const container = store.currentContainer(id);
|
||||
|
||||
provide("container", container);
|
||||
|
||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
|
||||
|
||||
function onClearClicked() {
|
||||
|
||||
@@ -6,29 +6,10 @@ import LogEventSource from "./LogEventSource.vue";
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { settings } from "../composables/settings";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { vi, describe, expect, beforeEach, test, beforeAll, afterAll } from "vitest";
|
||||
import { computed, Ref } from "vue";
|
||||
import { vi, describe, expect, beforeEach, test, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { computed, nextTick } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
vi.mock("lodash.debounce", () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn((fn) => {
|
||||
fn.cancel = () => {};
|
||||
return fn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/container", () => ({
|
||||
__esModule: true,
|
||||
useContainerStore() {
|
||||
return {
|
||||
currentContainer(id: Ref<string>) {
|
||||
return computed(() => ({ id: id.value }));
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "" },
|
||||
@@ -47,6 +28,13 @@ describe("<LogEventSource />", () => {
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1560336942459);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createLogEventSource(
|
||||
@@ -78,13 +66,16 @@ describe("<LogEventSource />", () => {
|
||||
components: {
|
||||
LogViewer,
|
||||
},
|
||||
provide: {
|
||||
container: computed(() => ({ id: "abc", image: "test:v123" })),
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: `
|
||||
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
|
||||
`,
|
||||
},
|
||||
props: { id: "abc" },
|
||||
props: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,68 +102,27 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2019-06-12T10:55:42.459034602Z");
|
||||
expect(messageWithoutKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should parse messages with loki's timestamp format", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ data: `2020-04-27T12:35:43.272974324+02:00 xxxxx` });
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2020-04-27T12:35:43.272974324+02:00");
|
||||
expect(messageWithoutKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should pass messages to slot", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
});
|
||||
const [message, _] = wrapper.getComponent(LogViewer).vm.messages;
|
||||
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2019-06-12T10:55:42.459034602Z");
|
||||
|
||||
expect(messageWithoutKey).toMatchSnapshot();
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
const RealDate = Date;
|
||||
beforeAll(() => {
|
||||
// @ts-ignore
|
||||
global.Date = class extends RealDate {
|
||||
constructor(arg: any | number) {
|
||||
super(arg);
|
||||
if (arg) {
|
||||
return new RealDate(arg);
|
||||
} else {
|
||||
return new RealDate(1560336936000);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
afterAll(() => (global.Date = RealDate));
|
||||
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -180,10 +130,12 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z \x1b[30mblack\x1b[37mwhite`,
|
||||
data: '{"ts":1560336942459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}',
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -191,10 +143,12 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z <test>foo bar</test>`,
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -202,10 +156,12 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -213,10 +169,12 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -224,13 +182,15 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource({ searchFilter: "test" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-11T10:55:42.459034602Z Foo bar`,
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z This is a test <hi></hi>`,
|
||||
data: `{"ts":1560336942459, "m":"test bar", "id":2}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,133 +1,25 @@
|
||||
<template>
|
||||
<infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader>
|
||||
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, ref, watch, onUnmounted } from "vue";
|
||||
import debounce from "lodash.debounce";
|
||||
import { useEventSource } from "@/composables/eventsource";
|
||||
import { Container } from "@/types/Container";
|
||||
import { inject, ComputedRef } from "vue";
|
||||
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import InfiniteLoader from "./InfiniteLoader.vue";
|
||||
import config from "@/stores/config";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { id } = toRefs(props);
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer(id);
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
const { connect, messages, loadOlderLogs } = useEventSource(container);
|
||||
|
||||
const messages = ref<LogEntry[]>([]);
|
||||
const buffer = ref<LogEntry[]>([]);
|
||||
|
||||
function flushNow() {
|
||||
messages.value.push(...buffer.value);
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let lastEventId = "";
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
es?.close();
|
||||
|
||||
if (clear) {
|
||||
flushBuffer.cancel();
|
||||
messages.value = [];
|
||||
buffer.value = [];
|
||||
lastEventId = "";
|
||||
}
|
||||
|
||||
es = new EventSource(`${config.base}/api/logs/stream?id=${props.id}&lastEventId=${lastEventId}`);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.value.push({
|
||||
event: "container-stopped",
|
||||
message: "Container stopped",
|
||||
date: new Date(),
|
||||
key: new Date().toString(),
|
||||
});
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
es.onmessage = (e) => {
|
||||
lastEventId = e.lastEventId;
|
||||
if (e.data) {
|
||||
buffer.value.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (messages.value.length < 300) return;
|
||||
|
||||
emit("loading-more", true);
|
||||
const to = messages.value[0].date;
|
||||
const last = messages.value[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value.unshift(...newMessages);
|
||||
}
|
||||
emit("loading-more", false);
|
||||
}
|
||||
|
||||
function parseMessage(data: String): LogEntry {
|
||||
let i = data.indexOf(" ");
|
||||
if (i == -1) {
|
||||
i = data.length;
|
||||
}
|
||||
const key = data.substring(0, i);
|
||||
const date = new Date(key);
|
||||
const message = data.substring(i + 1);
|
||||
return { key, date, message };
|
||||
}
|
||||
|
||||
watch(
|
||||
() => container.value.state,
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.value.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
key: new Date().toString(),
|
||||
});
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
watch(id, () => connect());
|
||||
const beforeLoading = () => emit("loading-more", true);
|
||||
const afterLoading = () => emit("loading-more", false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
|
||||
|
||||
connect();
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<ul class="events" ref="events" :class="{ 'disable-wrap': !softWrap, [size]: true }">
|
||||
<li
|
||||
v-for="(item, index) in filtered"
|
||||
:key="item.key"
|
||||
:data-key="item.key"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:data-event="item.event"
|
||||
:class="{ selected: item.selected }"
|
||||
:class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
>
|
||||
<div class="line-options" v-show="isSearching()">
|
||||
<dropdown-menu :class="{ 'is-last': index === filtered.length - 1 }" class="is-top minimal">
|
||||
<a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.key}`">
|
||||
<a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.id}`">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
@@ -25,20 +25,27 @@
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="date" v-if="showTimestamp"> <relative-time :date="item.date"></relative-time></span>
|
||||
<span class="text" v-html="colorize(item.message)"></span>
|
||||
<JSONPayload :log-entry="item" :visible-keys="visibleKeys.value" v-if="item.isComplex()"></JSONPayload>
|
||||
<span class="text" v-html="colorize(item.message)" v-if="item.isSimple()"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType, ref, toRefs, watch } from "vue";
|
||||
import { ComputedRef, inject, PropType, ref, toRefs, watch, toRaw } from "vue";
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
import { size, showTimestamp, softWrap } from "@/composables/settings";
|
||||
import RelativeTime from "./RelativeTime.vue";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { useVisibleFilter } from "@/composables/visible";
|
||||
import { Container } from "@/types/Container";
|
||||
import { persistentVisibleKeys } from "@/utils";
|
||||
|
||||
import RelativeTime from "./RelativeTime.vue";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import JSONPayload from "./JSONPayload.vue";
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
@@ -48,18 +55,22 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
|
||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
|
||||
const { messages } = toRefs(props);
|
||||
const filtered = filteredMessages(messages);
|
||||
let visibleKeys = persistentVisibleKeys(inject("container") as ComputedRef<Container>);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
|
||||
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
const events = ref<HTMLElement>();
|
||||
let lastSelectedItem: LogEntry | undefined = undefined;
|
||||
function handleJumpLineSelected(e: Event, item: LogEntry) {
|
||||
if (lastSelectedItem) {
|
||||
lastSelectedItem.selected = false;
|
||||
}
|
||||
lastSelectedItem = item;
|
||||
item.selected = true;
|
||||
let lastSelectedItem = ref<VisibleLogEntry>();
|
||||
|
||||
function handleJumpLineSelected(e: Event, item: VisibleLogEntry) {
|
||||
lastSelectedItem.value = item;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
@@ -84,6 +95,13 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
&::before {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
word-wrap: break-word;
|
||||
@@ -95,7 +113,7 @@ watch(
|
||||
&:nth-child(odd) {
|
||||
background-color: rgba(125, 125, 125, 0.08);
|
||||
}
|
||||
&[data-event="container-stopped"] {
|
||||
&[data-event="container-stopped"] {
|
||||
color: #f14668;
|
||||
}
|
||||
&[data-event="container-started"] {
|
||||
@@ -167,13 +185,6 @@ watch(
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
&::before {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
:deep(mark) {
|
||||
border-radius: 2px;
|
||||
background-color: var(--secondary-color);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<log-event-source ref="source" :id="id" #default="{ messages }" @loading-more="emit('loading-more', $event)">
|
||||
<log-event-source ref="source" #default="{ messages }" @loading-more="emit('loading-more', $event)">
|
||||
<log-viewer :messages="messages"></log-viewer>
|
||||
</log-event-source>
|
||||
</template>
|
||||
@@ -7,12 +7,6 @@
|
||||
<script lang="ts" setup>
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { ref } from "vue";
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="is-scrollbar-notification">
|
||||
<transition name="fade">
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom()" v-show="paused">
|
||||
<mdi-light-chevron-double-down />
|
||||
</button>
|
||||
</transition>
|
||||
@@ -24,61 +24,51 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: {
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
name: "ScrollableView",
|
||||
data() {
|
||||
return {
|
||||
paused: false,
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
mutationObserver: null,
|
||||
intersectionObserver: null,
|
||||
};
|
||||
defineProps({
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mounted() {
|
||||
const { scrollableContent } = this.$refs;
|
||||
this.mutationObserver = new MutationObserver((e) => {
|
||||
if (!this.paused) {
|
||||
this.scrollToBottom("instant");
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (
|
||||
record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]
|
||||
) {
|
||||
this.hasMore = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.mutationObserver.observe(scrollableContent, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => (this.paused = entries[0].intersectionRatio == 0),
|
||||
{ threshholds: [0, 1], rootMargin: "80px 0px" }
|
||||
);
|
||||
this.intersectionObserver.observe(this.$refs.scrollObserver);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.mutationObserver.disconnect();
|
||||
this.intersectionObserver.disconnect();
|
||||
},
|
||||
methods: {
|
||||
scrollToBottom(behavior = "instant") {
|
||||
this.$refs.scrollObserver.scrollIntoView({ behavior });
|
||||
this.hasMore = false;
|
||||
},
|
||||
setLoading(loading) {
|
||||
this.loading = loading;
|
||||
},
|
||||
},
|
||||
};
|
||||
const paused = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const loading = ref(false);
|
||||
const scrollObserver = ref<HTMLElement>();
|
||||
const scrollableContent = ref<HTMLElement>();
|
||||
|
||||
const mutationObserver = new MutationObserver((e) => {
|
||||
if (!paused.value) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore.value = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => (paused.value = entries[0].intersectionRatio == 0), {
|
||||
threshholds: [0, 1],
|
||||
rootMargin: "80px 0px",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
mutationObserver.observe(scrollableContent.value!, { childList: true, subtree: true });
|
||||
intersectionObserver.observe(scrollObserver.value!);
|
||||
});
|
||||
|
||||
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
scrollObserver.value?.scrollIntoView({ behavior });
|
||||
hasMore.value = false;
|
||||
}
|
||||
|
||||
function setLoading(value: boolean) {
|
||||
loading.value = value;
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
section {
|
||||
|
||||
@@ -1,201 +1,200 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
|
||||
<li data-key=\\"2019-06-12T23:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T23:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 11:55:42 PM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
|
||||
<li data-key=\\"2019-06-12T23:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T23:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 23:55:42</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\">\\"This is a message.\\"</span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\">This is a message.</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\">This is a <mark>test</mark> <hi></hi></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><mark>test</mark> bar</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-28f125ea=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-28f125ea=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-3af6a38b=\\"\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-3af6a38b=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-3af6a38b=\\"\\"><span class=\\"icon\\" data-v-3af6a38b=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-3af6a38b=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-3af6a38b=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-3af6a38b=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-28f125ea=\\"\\">
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-28f125ea=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-28f125ea=\\"\\">Jump to Context</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > renders correctly 1`] = `
|
||||
"<div class=\\"infinte-loader\\" data-v-48dce4fc=\\"\\">
|
||||
<div class=\\"spinner\\" data-v-48dce4fc=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"bounce1\\" data-v-48dce4fc=\\"\\"></div>
|
||||
<div class=\\"bounce2\\" data-v-48dce4fc=\\"\\"></div>
|
||||
<div class=\\"bounce3\\" data-v-48dce4fc=\\"\\"></div>
|
||||
"<div class=\\"infinte-loader\\" data-v-1cd63c6e=\\"\\">
|
||||
<div class=\\"spinner\\" data-v-1cd63c6e=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"bounce1\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
<div class=\\"bounce2\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
<div class=\\"bounce3\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"></ul>"
|
||||
<ul class=\\"events medium\\" data-v-cce5b553=\\"\\"></ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages 1`] = `
|
||||
{
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"message": "\\"This is a message.\\"",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages with loki's timestamp format 1`] = `
|
||||
{
|
||||
"date": 2020-04-27T10:35:43.272Z,
|
||||
"message": "xxxxx",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should pass messages to slot 1`] = `
|
||||
{
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"message": "\\"This is a message.\\"",
|
||||
"id": 1,
|
||||
"message": "This is a message.",
|
||||
}
|
||||
`;
|
||||
|
||||
111
assets/composables/eventsource.ts
Normal file
111
assets/composables/eventsource.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ref, watch, onUnmounted, ComputedRef } from "vue";
|
||||
import debounce from "lodash.debounce";
|
||||
|
||||
import type { LogEntry, LogEvent } from "@/types/LogEntry";
|
||||
|
||||
import config from "@/stores/config";
|
||||
import { Container } from "@/types/Container";
|
||||
|
||||
function parseMessage(data: string): LogEntry {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
|
||||
const id = e.id;
|
||||
const date = new Date(e.ts);
|
||||
return { id, date, message: e.m };
|
||||
}
|
||||
|
||||
export function useEventSource(container: ComputedRef<Container>) {
|
||||
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=${container.value.id}&lastEventId=${lastEventId}`);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.value.push({
|
||||
event: "container-stopped",
|
||||
message: "Container stopped",
|
||||
date: new Date(),
|
||||
id: new Date().getTime(),
|
||||
});
|
||||
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({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
|
||||
if (messages.value.length < 300) return;
|
||||
|
||||
beforeLoading();
|
||||
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=${container.value.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value.unshift(...newMessages);
|
||||
}
|
||||
afterLoading();
|
||||
}
|
||||
|
||||
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(),
|
||||
id: new Date().getTime(),
|
||||
});
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => container.value.id,
|
||||
() => connect()
|
||||
);
|
||||
|
||||
return { connect, messages, loadOlderLogs };
|
||||
}
|
||||
@@ -1,21 +1,42 @@
|
||||
import { ref, computed, Ref } from "vue";
|
||||
import { useDebounce } from "@vueuse/core";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
|
||||
const searchFilter = ref<string>("");
|
||||
const debouncedSearchFilter = useDebounce(searchFilter);
|
||||
const showSearch = ref(false);
|
||||
|
||||
import type { LogEntry } from "@/types/LogEntry";
|
||||
function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
for (const key in record) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && regex.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value) && matchRecord(value, regex)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useSearchFilter() {
|
||||
const regex = computed(() => {
|
||||
const isSmartCase = searchFilter.value === searchFilter.value.toLowerCase();
|
||||
return isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value);
|
||||
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
|
||||
return isSmartCase ? new RegExp(debouncedSearchFilter.value, "i") : new RegExp(debouncedSearchFilter.value);
|
||||
});
|
||||
|
||||
function filteredMessages(messages: Ref<LogEntry[]>) {
|
||||
function filteredMessages(messages: Ref<VisibleLogEntry[]>) {
|
||||
return computed(() => {
|
||||
if (searchFilter && searchFilter.value) {
|
||||
if (debouncedSearchFilter.value) {
|
||||
try {
|
||||
return messages.value.filter((d) => d.message.match(regex.value));
|
||||
return messages.value.filter((d) => {
|
||||
if (d.isSimple()) {
|
||||
return regex.value.test(d.message);
|
||||
} else if (d.isComplex()) {
|
||||
return matchRecord(d.message, regex.value);
|
||||
}
|
||||
throw new Error("Unknown message type");
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
console.info(`Ignoring SyntaxError from search.`, e);
|
||||
@@ -29,11 +50,17 @@ export function useSearchFilter() {
|
||||
});
|
||||
}
|
||||
|
||||
function markSearch(log: string) {
|
||||
if (searchFilter && searchFilter.value) {
|
||||
return log.replace(regex.value, `<mark>$&</mark>`);
|
||||
function markSearch(log: string): string;
|
||||
function markSearch(log: string[]): string[];
|
||||
function markSearch(log: string | string[]) {
|
||||
if (!debouncedSearchFilter.value) {
|
||||
return log;
|
||||
}
|
||||
return log;
|
||||
if (Array.isArray(log)) {
|
||||
return log.map((d) => markSearch(d));
|
||||
}
|
||||
|
||||
return log.toString().replace(regex.value, (match) => `<mark>${match}</mark>`);
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
|
||||
13
assets/composables/visible.ts
Normal file
13
assets/composables/visible.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { computed, ComputedRef, Ref } from "vue";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: ComputedRef<Ref<string[][]>>) {
|
||||
function filteredPayload(messages: Ref<LogEntry[]>) {
|
||||
return computed(() => {
|
||||
return messages.value.map((d) => new VisibleLogEntry(d, visibleKeys.value));
|
||||
});
|
||||
}
|
||||
|
||||
return { filteredPayload };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ $menu-item-hover-color: var(--menu-item-hover-color);
|
||||
|
||||
$text-strong: var(--text-strong-color);
|
||||
$text: var(--text-color);
|
||||
$text-light: var(--text-light-color);
|
||||
|
||||
$panel-heading-background-color: var(--panel-heading-background-color);
|
||||
$panel-heading-color: var(--panel-heading-color);
|
||||
@@ -38,8 +39,7 @@ $light-toolbar-color: rgba($grey-darker, 0.7);
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/skeleton.scss";
|
||||
@import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
html,
|
||||
[data-theme="dark"] {
|
||||
@mixin dark {
|
||||
--scheme-main: #{$black};
|
||||
--scheme-main-bis: #{$black-bis};
|
||||
--scheme-main-ter: #{$black-ter};
|
||||
@@ -64,93 +64,57 @@ html,
|
||||
|
||||
--text-strong-color: #{$grey-lightest};
|
||||
--text-color: #{$grey-lighter};
|
||||
--text-light-color: #{$grey};
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
|
||||
--border-color: #{$grey-lighter};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: #{$grey-darker};
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
--text-light-color: #{$grey};
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@include dark;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@include light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
--scheme-main: #{$black};
|
||||
--scheme-main-bis: #{$black-bis};
|
||||
--scheme-main-ter: #{$black-ter};
|
||||
|
||||
--border-color: #{$grey-darker};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: var(--secondary-color);
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #{$yellow};
|
||||
|
||||
--body-background-color: #{$black-bis};
|
||||
--action-toolbar-background-color: #{$dark-toolbar-color};
|
||||
|
||||
--menu-item-active-background-color: var(--primary-color);
|
||||
--menu-item-color: hsl(0, 6%, 87%);
|
||||
--menu-item-hover-background-color: #{$white-ter};
|
||||
--menu-item-hover-color: #{$black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--scheme-main-bis);
|
||||
|
||||
--text-strong-color: #{$grey-lightest};
|
||||
--text-color: #{$grey-lighter};
|
||||
@include dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
html {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
|
||||
--border-color: #{$grey-lighter};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: #{$grey-darker};
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
@include light;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
|
||||
--border-color: #{$grey-lighter};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: #{$grey-darker};
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: unset;
|
||||
overflow-y: unset;
|
||||
|
||||
1
assets/types/Container.d.ts
vendored
1
assets/types/Container.d.ts
vendored
@@ -4,6 +4,7 @@ export interface Container {
|
||||
readonly image: string;
|
||||
readonly name: string;
|
||||
readonly status: string;
|
||||
readonly command: string;
|
||||
state: "created" | "running" | "exited" | "dead" | "paused" | "restarting";
|
||||
stat?: ContainerStat;
|
||||
}
|
||||
|
||||
15
assets/types/LogEntry.d.ts
vendored
15
assets/types/LogEntry.d.ts
vendored
@@ -1,7 +1,16 @@
|
||||
export interface LogEntry {
|
||||
date: Date;
|
||||
message: string;
|
||||
key: string;
|
||||
readonly date: Date;
|
||||
readonly message: string | JSONObject;
|
||||
readonly id: number;
|
||||
event?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface LogEvent {
|
||||
readonly m: string | JSONObject;
|
||||
readonly ts: number;
|
||||
readonly id: number;
|
||||
}
|
||||
|
||||
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
|
||||
export type JSONObject = { [x: string]: JSONValue };
|
||||
|
||||
54
assets/types/VisibleLogEntry.ts
Normal file
54
assets/types/VisibleLogEntry.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { computed, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON, getDeep } from "@/utils";
|
||||
import type { JSONObject, LogEntry } from "./LogEntry";
|
||||
|
||||
export class VisibleLogEntry implements LogEntry {
|
||||
private readonly entry: LogEntry;
|
||||
filteredMessage: undefined | ComputedRef<Record<string, any>>;
|
||||
|
||||
constructor(entry: LogEntry, visibleKeys: Ref<string[][]>) {
|
||||
this.entry = entry;
|
||||
this.filteredMessage = undefined;
|
||||
if (this.isComplex()) {
|
||||
const message = this.message;
|
||||
this.filteredMessage = computed(() => {
|
||||
if (!visibleKeys.value.length) {
|
||||
return flattenJSON(message);
|
||||
} else {
|
||||
return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public isComplex(): this is { message: JSONObject } {
|
||||
return typeof this.entry.message === "object";
|
||||
}
|
||||
|
||||
public isSimple(): this is { message: string } {
|
||||
return !this.isComplex();
|
||||
}
|
||||
|
||||
public get unfilteredPayload(): JSONObject {
|
||||
if (typeof this.entry.message === "string") {
|
||||
throw new Error("Cannot get unfiltered payload of a simple message");
|
||||
}
|
||||
return this.entry.message;
|
||||
}
|
||||
|
||||
public get date(): Date {
|
||||
return this.entry.date;
|
||||
}
|
||||
|
||||
public get message(): string | JSONObject {
|
||||
return this.filteredMessage?.value ?? this.entry.message;
|
||||
}
|
||||
|
||||
public get id(): number {
|
||||
return this.entry.id;
|
||||
}
|
||||
|
||||
public get event(): string | undefined {
|
||||
return this.entry.event;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Container } from "@/types/Container";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed, ComputedRef } from "vue";
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
@@ -6,3 +10,38 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
|
||||
const result: Record<string, any> = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = obj[key];
|
||||
const newPath = path.concat(key);
|
||||
if (isObject(value)) {
|
||||
Object.assign(result, flattenJSON(value, newPath));
|
||||
} else {
|
||||
result[newPath.join(".")] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function arrayEquals(a: string[], b: string[]): boolean {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
|
||||
export function persistentVisibleKeys(container: ComputedRef<Container>) {
|
||||
return computed(() => useStorage(stripVersion(container.value.image) + ":" + container.value.command, []));
|
||||
}
|
||||
|
||||
export function stripVersion(label: string) {
|
||||
const [name, _] = label.split(":");
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type dockerProxy interface {
|
||||
Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error)
|
||||
ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
|
||||
ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error)
|
||||
Ping(ctx context.Context) (types.Ping, error)
|
||||
}
|
||||
|
||||
// Client is a proxy around the docker client
|
||||
@@ -38,6 +39,7 @@ type Client interface {
|
||||
Events(context.Context) (<-chan ContainerEvent, <-chan error)
|
||||
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time) (io.ReadCloser, error)
|
||||
ContainerStats(context.Context, string, chan<- ContainerStat) error
|
||||
Ping(context.Context) (types.Ping, error)
|
||||
}
|
||||
|
||||
// NewClientWithFilters creates a new instance of Client with docker filters
|
||||
@@ -136,10 +138,15 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan
|
||||
log.Errorf("decoder for stats api returned an unknown error %v", err)
|
||||
}
|
||||
|
||||
ncpus := uint8(v.CPUStats.OnlineCPUs)
|
||||
if ncpus == 0 {
|
||||
ncpus = uint8(len(v.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
|
||||
var (
|
||||
cpuDelta = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta = float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage)
|
||||
cpuPercent = int64((cpuDelta / systemDelta) * float64(len(v.CPUStats.CPUUsage.PercpuUsage)) * 100)
|
||||
cpuPercent = int64((cpuDelta / systemDelta) * float64(ncpus) * 100)
|
||||
memUsage = int64(v.MemoryStats.Usage - v.MemoryStats.Stats["cache"])
|
||||
memPercent = int64(float64(memUsage) / float64(v.MemoryStats.Limit) * 100)
|
||||
)
|
||||
@@ -165,6 +172,14 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan
|
||||
func (d *dockerClient) ContainerLogs(ctx context.Context, id string, tailSize int, since string) (io.ReadCloser, error) {
|
||||
log.WithField("id", id).WithField("since", since).Debug("streaming logs for container")
|
||||
|
||||
if since != "" {
|
||||
if millis, err := strconv.ParseInt(since, 10, 64); err == nil {
|
||||
since = time.UnixMicro(millis).Add(time.Millisecond).Format(time.RFC3339Nano)
|
||||
} else {
|
||||
log.WithError(err).Debug("unable to parse since")
|
||||
}
|
||||
}
|
||||
|
||||
options := types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
@@ -241,3 +256,7 @@ func (d *dockerClient) ContainerLogsBetweenDates(ctx context.Context, id string,
|
||||
|
||||
return newLogReader(reader, containerJSON.Config.Tty), nil
|
||||
}
|
||||
|
||||
func (d *dockerClient) Ping(ctx context.Context) (types.Ping, error) {
|
||||
return d.cli.Ping(ctx)
|
||||
}
|
||||
|
||||
@@ -26,3 +26,9 @@ type ContainerEvent struct {
|
||||
ActorID string `json:"actorId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
Message any `json:"m,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Id uint32 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM cypress/included:9.6.1
|
||||
FROM cypress/included:10.6.0
|
||||
|
||||
RUN apt install curl && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
|
||||
|
||||
@@ -7,5 +7,5 @@ WORKDIR /e2e
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch
|
||||
|
||||
COPY package.json ./
|
||||
COPY package.json tsconfig.json ./
|
||||
RUN pnpm install -r --offline
|
||||
|
||||
13
e2e/cypress.config.ts
Normal file
13
e2e/cypress.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import { initPlugin } from '@frsource/cypress-plugin-visual-regression-diff/dist/plugins';
|
||||
|
||||
export default defineConfig({
|
||||
fixturesFolder: false,
|
||||
projectId: "8cua4m",
|
||||
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
initPlugin(on, config);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"DOZZLE_DEFAULT": "http://localhost:3000/"
|
||||
"DOZZLE_DEFAULT": "http://localhost:8080/"
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"fixturesFolder": false,
|
||||
"projectId": "8cua4m"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -6,7 +6,7 @@ context("Dozzle default mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () =>
|
||||
});
|
||||
|
||||
it("home screen", () => {
|
||||
cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImageSnapshot();
|
||||
cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImage();
|
||||
});
|
||||
|
||||
it("correct title", () => {
|
||||
12
e2e/cypress/e2e/dozzle_dark.cy.js
Normal file
12
e2e/cypress/e2e/dozzle_dark.cy.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
context("Dozzle dark mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/");
|
||||
cy.window().then((win) => win.document.documentElement.setAttribute("data-theme", "dark"));
|
||||
});
|
||||
|
||||
it("home screen", () => {
|
||||
cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImage();
|
||||
});
|
||||
});
|
||||
5
e2e/cypress/fixtures/example.json
Normal file
5
e2e/cypress/fixtures/example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
context.skip("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();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
const { addMatchImageSnapshotPlugin } = require("cypress-image-snapshot/plugin");
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
|
||||
addMatchImageSnapshotPlugin(on, config);
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -1,5 +1,6 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
@@ -23,10 +24,17 @@
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
|
||||
import { addMatchImageSnapshotCommand } from "cypress-image-snapshot/command";
|
||||
|
||||
addMatchImageSnapshotCommand();
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
Cypress.Commands.add("removeDates", () => {
|
||||
cy.window().then((win) => win.document.querySelectorAll("time").forEach((el) => el.remove()));
|
||||
@@ -1,5 +1,5 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// This example support/e2e.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
@@ -14,7 +14,8 @@
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import "./commands";
|
||||
import './commands'
|
||||
import '@frsource/cypress-plugin-visual-regression-diff/dist/support';
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
14
e2e/cypress/tsconfig.json
Normal file
14
e2e/cypress/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
// be explicit about types included
|
||||
// to avoid clashing with Jest types
|
||||
"types": ["cypress"]
|
||||
},
|
||||
"include": [
|
||||
"../node_modules/cypress",
|
||||
"./**/*.ts",
|
||||
"./**/*.js"
|
||||
]
|
||||
}
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
working_dir: /e2e
|
||||
volumes:
|
||||
- ./cypress:/e2e/cypress
|
||||
- ./cypress.json:/e2e/cypress.json
|
||||
- ./cypress.config.ts:/e2e/cypress.config.ts
|
||||
environment:
|
||||
- CYPRESS_DOZZLE_DEFAULT=http://dozzle:8080/
|
||||
- CYPRESS_CUSTOM_DEFAULT=http://custom_base:8080/foobarbase
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"scripts": {},
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cypress": "^9.5.4",
|
||||
"cypress-image-snapshot": "^4.0.1"
|
||||
"@frsource/cypress-plugin-visual-regression-diff": "^1.9.2",
|
||||
"cypress": "^10.6.0",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
|
||||
710
e2e/pnpm-lock.yaml
generated
710
e2e/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
8
e2e/tsconfig.json
Normal file
8
e2e/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
14
go.mod
14
go.mod
@@ -5,7 +5,7 @@ require (
|
||||
github.com/alexflint/go-arg v1.4.3
|
||||
github.com/beme/abide v0.0.0-20190723115211-635a09831760
|
||||
github.com/docker/distribution v2.7.1+incompatible // indirect
|
||||
github.com/docker/docker v20.10.16+incompatible
|
||||
github.com/docker/docker v20.10.17+incompatible
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
github.com/docker/go-units v0.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
@@ -18,13 +18,13 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.0.1 // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/spf13/afero v1.8.2
|
||||
github.com/stretchr/objx v0.3.0 // indirect
|
||||
github.com/stretchr/testify v1.7.1
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
github.com/spf13/afero v1.9.2
|
||||
github.com/stretchr/objx v0.4.0 // indirect
|
||||
github.com/stretchr/testify v1.8.0
|
||||
golang.org/x/net v0.0.0-20211104170005-ce137452f963 // indirect
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
28
go.sum
28
go.sum
@@ -62,8 +62,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
|
||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v20.10.16+incompatible h1:2Db6ZR/+FUR3hqPMwnogOPHFn405crbpxvWzKovETOQ=
|
||||
github.com/docker/docker v20.10.16+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v20.10.17+incompatible h1:JYCuMrWaVNophQTOrMMoSwudOVEfcegoZZrleKc1xwE=
|
||||
github.com/docker/docker v20.10.17+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
|
||||
@@ -179,21 +179,21 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
|
||||
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
||||
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw=
|
||||
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -333,8 +333,8 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -499,8 +499,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
|
||||
32
healthcheck/http.go
Normal file
32
healthcheck/http.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func HttpRequest(addr string, base string) error {
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "localhost" + addr
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s%s/healthcheck", addr, base)
|
||||
log.Info("Checking health of " + url)
|
||||
resp, err := http.Get(url)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Config } from "@jest/types";
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
testPathIgnorePatterns: ["node_modules", "<rootDir>/integration/", "<rootDir>/e2e/"],
|
||||
transform: {
|
||||
"^.+\\.vue$": "@vue/vue3-jest",
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"@/(.*)": ["<rootDir>/assets/$1"],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
17
main.go
17
main.go
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/alexflint/go-arg"
|
||||
"github.com/amir20/dozzle/analytics"
|
||||
"github.com/amir20/dozzle/docker"
|
||||
"github.com/amir20/dozzle/healthcheck"
|
||||
"github.com/amir20/dozzle/web"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -29,13 +30,16 @@ type args struct {
|
||||
Base string `arg:"env:DOZZLE_BASE" default:"/" help:"sets the base for http router."`
|
||||
Level string `arg:"env:DOZZLE_LEVEL" default:"info" help:"set Dozzle log level. Use debug for more logging."`
|
||||
TailSize int `arg:"env:DOZZLE_TAILSIZE" default:"300" help:"update the initial tail size when fetching logs."`
|
||||
Key string `arg:"env:DOZZLE_KEY" help:"set a random key for username and password. This is required for auth."`
|
||||
Username string `arg:"env:DOZZLE_USERNAME" help:"sets the username for auth."`
|
||||
Password string `arg:"env:DOZZLE_PASSWORD" help:"sets password for auth"`
|
||||
NoAnalytics bool `arg:"--no-analytics,env:DOZZLE_NO_ANALYTICS" help:"disables anonymous analytics"`
|
||||
WaitForDockerSeconds int `arg:"--wait-for-docker-seconds,env:DOZZLE_WAIT_FOR_DOCKER_SECONDS" help:"wait for docker to be available for at most this many seconds before starting the server."`
|
||||
FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."`
|
||||
Filter map[string][]string `arg:"-"`
|
||||
Healthcheck *HealthcheckCmd `arg:"subcommand:healthcheck" help:"checks if the server is running."`
|
||||
}
|
||||
|
||||
type HealthcheckCmd struct {
|
||||
}
|
||||
|
||||
func (args) Version() string {
|
||||
@@ -69,6 +73,12 @@ func main() {
|
||||
DisableLevelTruncation: true,
|
||||
})
|
||||
|
||||
if args.Healthcheck != nil {
|
||||
if err := healthcheck.HttpRequest(args.Addr, args.Base); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("Dozzle version %s", version)
|
||||
dockerClient := docker.NewClientWithFilters(args.Filter)
|
||||
for i := 1; ; i++ {
|
||||
@@ -88,10 +98,6 @@ func main() {
|
||||
if args.Username == "" || args.Password == "" {
|
||||
log.Fatalf("Username AND password are required for authentication")
|
||||
}
|
||||
|
||||
if args.Key == "" {
|
||||
log.Fatalf("Key is required for authentication")
|
||||
}
|
||||
}
|
||||
|
||||
config := web.Config{
|
||||
@@ -99,7 +105,6 @@ func main() {
|
||||
Base: args.Base,
|
||||
Version: version,
|
||||
TailSize: args.TailSize,
|
||||
Key: args.Key,
|
||||
Username: args.Username,
|
||||
Password: args.Password,
|
||||
}
|
||||
|
||||
71
package.json
71
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dozzle",
|
||||
"version": "3.12.6",
|
||||
"version": "4.0.1",
|
||||
"description": "Realtime log viewer for docker containers. ",
|
||||
"homepage": "https://github.com/amir20/dozzle#readme",
|
||||
"bugs": {
|
||||
@@ -22,53 +22,54 @@
|
||||
"postinstall": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/carbon": "^1.1.4",
|
||||
"@iconify-json/cil": "^1.1.1",
|
||||
"@iconify-json/mdi": "^1.1.12",
|
||||
"@iconify-json/mdi-light": "^1.1.1",
|
||||
"@iconify-json/octicon": "^1.1.7",
|
||||
"@iconify-json/carbon": "^1.1.7",
|
||||
"@iconify-json/cil": "^1.1.2",
|
||||
"@iconify-json/mdi": "^1.1.30",
|
||||
"@iconify-json/mdi-light": "^1.1.2",
|
||||
"@iconify-json/octicon": "^1.1.16",
|
||||
"@oruga-ui/oruga-next": "^0.5.4",
|
||||
"@oruga-ui/theme-bulma": "^0.2.5",
|
||||
"@vitejs/plugin-vue": "^2.3.3",
|
||||
"@vue/compiler-sfc": "^3.2.33",
|
||||
"@vueuse/core": "^8.4.2",
|
||||
"@vueuse/router": "^8.4.2",
|
||||
"@oruga-ui/theme-bulma": "^0.2.6",
|
||||
"@vitejs/plugin-vue": "3.0.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"@vueuse/core": "^9.1.0",
|
||||
"@vueuse/router": "^9.1.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"bulma": "^0.9.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"fuzzysort": "^1.9.0",
|
||||
"hotkeys-js": "^3.9.3",
|
||||
"date-fns": "^2.29.2",
|
||||
"fuzzysort": "^2.0.1",
|
||||
"hotkeys-js": "^3.9.4",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"pinia": "^2.0.14",
|
||||
"sass": "^1.51.0",
|
||||
"pinia": "^2.0.20",
|
||||
"sass": "^1.54.4",
|
||||
"semver": "^7.3.7",
|
||||
"splitpanes": "^3.1.1",
|
||||
"typescript": "^4.6.4",
|
||||
"unplugin-auto-import": "^0.7.1",
|
||||
"unplugin-icons": "^0.14.3",
|
||||
"unplugin-vue-components": "^0.19.5",
|
||||
"vite": "^2.9.9",
|
||||
"vue": "^3.2.33",
|
||||
"vue-router": "^4.0.15"
|
||||
"typescript": "^4.7.4",
|
||||
"unplugin-auto-import": "^0.11.2",
|
||||
"unplugin-icons": "^0.14.8",
|
||||
"unplugin-vue-components": "^0.22.4",
|
||||
"vite": "3.0.9",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pinia/testing": "^0.0.12",
|
||||
"@types/jest": "^27.5.1",
|
||||
"@pinia/testing": "^0.0.14",
|
||||
"@types/jest": "^28.1.7",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^17.0.32",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@vue/test-utils": "^2.0.0-rc.21",
|
||||
"c8": "^7.11.2",
|
||||
"@types/node": "^18.7.7",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@vue/test-utils": "^2.0.2",
|
||||
"c8": "^7.12.0",
|
||||
"eventsourcemock": "^2.0.0",
|
||||
"husky": "^8.0.1",
|
||||
"jest-serializer-vue": "^2.0.2",
|
||||
"jsdom": "^19.0.0",
|
||||
"lint-staged": "^12.4.1",
|
||||
"jsdom": "^20.0.0",
|
||||
"lint-staged": "^13.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.6.2",
|
||||
"release-it": "^15.0.0",
|
||||
"ts-node": "^10.6.0",
|
||||
"vitest": "^0.12.4"
|
||||
"prettier": "^2.7.1",
|
||||
"release-it": "^15.3.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"vitest": "^0.22.1",
|
||||
"vue-tsc": "^0.40.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue,css}": [
|
||||
|
||||
2490
pnpm-lock.yaml
generated
2490
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": ["assets/*"]
|
||||
}
|
||||
},
|
||||
"jsx": "preserve",
|
||||
},
|
||||
"include": ["assets/**/*.ts", "assets/**/*.d.ts", "assets/**/*.vue"],
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Connection: keep-alive
|
||||
Content-Type: text/event-stream
|
||||
X-Accel-Buffering: no
|
||||
|
||||
data: INFO Testing logs...
|
||||
data: {"m":"INFO Testing logs...","ts":0,"id":4256192898}
|
||||
|
||||
event: container-stopped
|
||||
data: end of stream
|
||||
@@ -170,8 +170,8 @@ Connection: keep-alive
|
||||
Content-Type: text/event-stream
|
||||
X-Accel-Buffering: no
|
||||
|
||||
data: 2020-05-13T18:55:37.772853839Z INFO Testing logs...
|
||||
id: 2020-05-13T18:55:37.772853839Z
|
||||
data: {"m":"INFO Testing logs...","ts":1589396137772,"id":1469707724}
|
||||
id: 1589396137772
|
||||
|
||||
event: container-stopped
|
||||
data: end of stream
|
||||
11
web/auth.go
11
web/auth.go
@@ -1,6 +1,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -17,7 +19,7 @@ const sessionName = "session"
|
||||
func initializeAuth(h *handler) {
|
||||
secured = false
|
||||
if h.config.Username != "" && h.config.Password != "" {
|
||||
store = sessions.NewCookieStore([]byte(h.config.Key))
|
||||
store = sessions.NewCookieStore(generateSessionStorageKey(h.config.Username, h.config.Password))
|
||||
store.Options.HttpOnly = true
|
||||
store.Options.SameSite = http.SameSiteLaxMode
|
||||
store.Options.MaxAge = 0
|
||||
@@ -35,7 +37,7 @@ func authorizationRequired(f http.HandlerFunc) http.Handler {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return http.HandlerFunc(f)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,3 +117,8 @@ func (h *handler) clearSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
http.Redirect(w, r, h.config.Base, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func generateSessionStorageKey(username string, password string) []byte {
|
||||
key := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", username, password)))
|
||||
return key[:]
|
||||
}
|
||||
|
||||
114
web/logs.go
114
web/logs.go
@@ -4,6 +4,8 @@ import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,29 +15,12 @@ import (
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/docker"
|
||||
"github.com/dustin/go-humanize"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||
|
||||
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
||||
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
||||
id := r.URL.Query().Get("id")
|
||||
|
||||
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
|
||||
defer reader.Close()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
io.Copy(w, reader)
|
||||
}
|
||||
|
||||
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
container, err := h.client.FindContainer(id)
|
||||
@@ -63,6 +48,64 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(zw, reader)
|
||||
}
|
||||
|
||||
func logEventIterator(reader *bufio.Reader) func() (docker.LogEvent, error) {
|
||||
return func() (docker.LogEvent, error) {
|
||||
message, readerError := reader.ReadString('\n')
|
||||
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(message))
|
||||
|
||||
logEvent := docker.LogEvent{Id: h.Sum32(), Message: message}
|
||||
|
||||
if index := strings.IndexAny(message, " "); index != -1 {
|
||||
logId := message[:index]
|
||||
if timestamp, err := time.Parse(time.RFC3339Nano, logId); err == nil {
|
||||
logEvent.Timestamp = timestamp.UnixMilli()
|
||||
message = strings.TrimSuffix(message[index+1:], "\n")
|
||||
logEvent.Message = message
|
||||
if strings.HasPrefix(message, "{") && strings.HasSuffix(message, "}") {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(message), &data); err != nil {
|
||||
log.Errorf("json unmarshal error while streaming %v", err.Error())
|
||||
}
|
||||
logEvent.Message = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logEvent, readerError
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/ld+json; charset=UTF-8")
|
||||
|
||||
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
||||
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
||||
id := r.URL.Query().Get("id")
|
||||
|
||||
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
|
||||
defer reader.Close()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
buffered := bufio.NewReader(reader)
|
||||
eventIterator := logEventIterator(buffered)
|
||||
|
||||
for {
|
||||
logEvent, readerError := eventIterator()
|
||||
if readerError != nil {
|
||||
break
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(logEvent); err != nil {
|
||||
log.Errorf("json encoding error while streaming %v", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
@@ -105,17 +148,36 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
fmt.Fprintf(w, ":ping \n\n")
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
buffered := bufio.NewReader(reader)
|
||||
var readerError error
|
||||
var message string
|
||||
eventIterator := logEventIterator(buffered)
|
||||
|
||||
for {
|
||||
message, readerError = buffered.ReadString('\n')
|
||||
fmt.Fprintf(w, "data: %s\n", message)
|
||||
if index := strings.IndexAny(message, " "); index != -1 {
|
||||
id := message[:index]
|
||||
if _, err := time.Parse(time.RFC3339Nano, id); err == nil {
|
||||
fmt.Fprintf(w, "id: %s\n", id)
|
||||
}
|
||||
|
||||
var logEvent docker.LogEvent
|
||||
logEvent, readerError = eventIterator()
|
||||
if buf, err := json.Marshal(logEvent); err != nil {
|
||||
log.Errorf("json encoding error while streaming %v", err.Error())
|
||||
} else {
|
||||
fmt.Fprintf(w, "data: %s\n", buf)
|
||||
}
|
||||
if logEvent.Timestamp > 0 {
|
||||
fmt.Fprintf(w, "id: %d\n", logEvent.Timestamp)
|
||||
}
|
||||
fmt.Fprintf(w, "\n")
|
||||
f.Flush()
|
||||
|
||||
@@ -20,7 +20,6 @@ type Config struct {
|
||||
Addr string
|
||||
Version string
|
||||
TailSize int
|
||||
Key string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
@@ -50,9 +49,9 @@ func createRouter(h *handler) *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
r.Use(cspHeaders)
|
||||
if base != "/" {
|
||||
r.HandleFunc(base, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
r.HandleFunc(base, func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, base+"/", http.StatusMovedPermanently)
|
||||
}))
|
||||
})
|
||||
}
|
||||
s := r.PathPrefix(base).Subrouter()
|
||||
s.Handle("/api/logs/stream", authorizationRequired(h.streamLogs))
|
||||
@@ -62,6 +61,7 @@ func createRouter(h *handler) *mux.Router {
|
||||
s.HandleFunc("/api/validateCredentials", h.validateCredentials)
|
||||
s.Handle("/logout", authorizationRequired(h.clearSession))
|
||||
s.Handle("/version", authorizationRequired(h.version))
|
||||
s.HandleFunc("/healthcheck", h.healthcheck)
|
||||
|
||||
if log.IsLevelEnabled(log.DebugLevel) {
|
||||
s.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)
|
||||
@@ -132,3 +132,14 @@ func (h *handler) version(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, "<pre>%v</pre>", h.config.Version)
|
||||
}
|
||||
|
||||
func (h *handler) healthcheck(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace("Executing healthcheck request")
|
||||
|
||||
if ping, err := h.client.Ping(r.Context()); err != nil {
|
||||
log.Error(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
fmt.Fprintf(w, "OK API Version %v", ping.APIVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ func Test_createRoutes_redirect_with_auth(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.")
|
||||
|
||||
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/foobar", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/foobar", Username: "amir", Password: "password"})
|
||||
req, err := http.NewRequest("GET", "/foobar/", nil)
|
||||
require.NoError(t, err, "NewRequest should not return an error.")
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -320,7 +320,7 @@ func Test_createRoutes_version(t *testing.T) {
|
||||
|
||||
func Test_createRoutes_username_password(t *testing.T) {
|
||||
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
|
||||
req, err := http.NewRequest("GET", "/", nil)
|
||||
require.NoError(t, err, "NewRequest should not return an error.")
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -329,7 +329,7 @@ func Test_createRoutes_username_password(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_createRoutes_username_password_invalid(t *testing.T) {
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
|
||||
req, err := http.NewRequest("GET", "/api/logs/stream?id=123", nil)
|
||||
require.NoError(t, err, "NewRequest should not return an error.")
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -338,7 +338,7 @@ func Test_createRoutes_username_password_invalid(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_createRoutes_username_password_login_happy(t *testing.T) {
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
@@ -368,7 +368,7 @@ func Test_createRoutes_username_password_login_happy(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_createRoutes_username_password_login_failed(t *testing.T) {
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
@@ -398,7 +398,7 @@ func Test_createRoutes_username_password_valid_session(t *testing.T) {
|
||||
mockedClient := new(MockedClient)
|
||||
mockedClient.On("FindContainer", "123").Return(docker.Container{ID: "123"}, nil)
|
||||
mockedClient.On("ContainerLogs", mock.Anything, "123", 0).Return(ioutil.NopCloser(strings.NewReader("test data")), io.EOF)
|
||||
handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"})
|
||||
|
||||
// Get cookie first
|
||||
req, err := http.NewRequest("GET", "/api/logs/stream?id=123", nil)
|
||||
@@ -422,7 +422,7 @@ func Test_createRoutes_username_password_invalid_session(t *testing.T) {
|
||||
mockedClient := new(MockedClient)
|
||||
mockedClient.On("FindContainer", "123").Return(docker.Container{ID: "123"}, nil)
|
||||
mockedClient.On("ContainerLogs", mock.Anything, "123", 0).Return(ioutil.NopCloser(strings.NewReader("test data")), io.EOF)
|
||||
handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password", Key: "key"})
|
||||
handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"})
|
||||
req, err := http.NewRequest("GET", "/api/logs/stream?id=123", nil)
|
||||
require.NoError(t, err, "NewRequest should not return an error.")
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "baddata"})
|
||||
|
||||
Reference in New Issue
Block a user