Compare commits

..

1 Commits

Author SHA1 Message Date
Amir Raminfar 485c2324af wip oidc 2025-03-30 09:44:00 -07:00
192 changed files with 4422 additions and 6301 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = true
stop_on_error = true
stop_on_error = false
[color]
app = ""
+7 -8
View File
@@ -48,18 +48,17 @@ body:
id: environment
attributes:
label: 💻 Environment
description: Run `docker system info` and paste the output in the field below.
validations:
required: true
- type: textarea
attributes:
label: 📜 Dozzle logs are required for debugging purposes. You may need to enable debug mode. See https://dozzle.dev/guide/debugging.
description: Drag log files or paste the logs into the text input below
description: Run `docker system info` and paste the output in the field below..
validations:
required: true
- type: textarea
attributes:
label: 📸 If applicable, add screenshots to help explain your bug
description: Drag screenshots into the text input below
description: Drag issues into the text input below
validations:
required: false
- type: textarea
attributes:
label: 📜 If applicable, attach your Dozzle logs. You many need to enable debug mode. See https://dozzle.dev/guide/debugging.
validations:
required: false
+3 -3
View File
@@ -31,7 +31,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.24.4
go-version: 1.24.1
check-latest: true
- name: Checkout code
uses: actions/checkout@v4
@@ -90,7 +90,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to DockerHub
uses: docker/login-action@v3.4.0
with:
@@ -121,7 +121,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v6.15.0
with:
push: true
context: .
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to DockerHub
uses: docker/login-action@v3.4.0
with:
@@ -39,7 +39,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v6.15.0
with:
context: .
push: true
+2 -2
View File
@@ -54,7 +54,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: "1.24.4"
go-version: "1.24.1"
check-latest: true
- name: Checkout code
uses: actions/checkout@v4
@@ -77,7 +77,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.4"
go-version: "1.24.1"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
+1 -2
View File
@@ -1,5 +1,5 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:23.11.1-alpine AS node
FROM --platform=$BUILDPLATFORM node:23.10.0-alpine AS node
RUN corepack enable
@@ -61,7 +61,6 @@ RUN mkdir /data
FROM scratch
COPY --from=builder /data /data
COPY --from=builder /tmp /tmp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /dozzle/dozzle /dozzle
+2 -2
View File
@@ -43,11 +43,11 @@ int:
docker compose up --build --force-recreate --exit-code-from playwright
shared_key.pem:
@openssl genpkey -algorithm Ed25519 -out shared_key.pem
@openssl genpkey -algorithm RSA -out shared_key.pem -pkeyopt rsa_keygen_bits:2048
shared_cert.pem: shared_key.pem
@openssl req -new -key shared_key.pem -out shared_request.csr -subj "/C=US/ST=California/L=San Francisco/O=Dozzle"
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 365
@rm shared_request.csr
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
+20 -1
View File
@@ -115,7 +115,26 @@ If you do not want to be tracked at all, see the `--no-analytics` flag below.
## Environment variables and configuration
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. See documentation at [https://dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. The table below outlines all supported options and their respective env vars.
| Flag | Env Variable | Default |
| --------------------- | -------------------------- | -------------- |
| `--addr` | `DOZZLE_ADDR` | `:8080` |
| `--base` | `DOZZLE_BASE` | `/` |
| `--hostname` | `DOZZLE_HOSTNAME` | `""` |
| `--level` | `DOZZLE_LEVEL` | `info` |
| `--auth-provider` | `DOZZLE_AUTH_PROVIDER` | `none` |
| `--auth-header-user` | `DOZZLE_AUTH_HEADER_USER` | `Remote-User` |
| `--auth-header-email` | `DOZZLE_AUTH_HEADER_EMAIL` | `Remote-Email` |
| `--auth-header-name` | `DOZZLE_AUTH_HEADER_NAME` | `Remote-Name` |
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
| `--filter` | `DOZZLE_FILTER` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
| `--mode` | `DOZZLE_MODE` | `server` |
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
| `--remote-agent` | `DOZZLE_REMOTE_AGENT` | |
| `--timeout` | `DOZZLE_TIMEOUT` | `10s` |
| `--namespace` | `DOZZLE_NAMESPACE` | `""` |
## Support
+1 -1
View File
@@ -15,7 +15,7 @@ watchEffect(() => {
if (theme === "auto") {
theme = mode.value;
}
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme == "dark" ? "#121212" : "#F5F5F5");
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme == "dark" ? "#121212" : "#3FA68F");
document.documentElement.setAttribute("data-theme", theme);
});
</script>
+1 -7
View File
@@ -69,7 +69,6 @@ declare global {
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const lightTheme: typeof import('./stores/settings')['lightTheme']
const loadBetween: typeof import('./composable/eventStreams')['loadBetween']
const locale: typeof import('./stores/settings')['locale']
const loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
@@ -152,7 +151,6 @@ declare global {
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toRelativeTime: typeof import('./utils/index')['toRelativeTime']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
@@ -240,7 +238,6 @@ declare global {
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
const useHead: typeof import('@vueuse/head')['useHead']
const useHistoricalContainerLog: typeof import('./composable/historicalLogs')['useHistoricalContainerLog']
const useHostStream: typeof import('./composable/eventStreams')['useHostStream']
const useHosts: typeof import('./stores/hosts')['useHosts']
const useI18n: typeof import('vue-i18n')['useI18n']
@@ -376,7 +373,7 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { DrawerWidth } from './composable/drawer'
@@ -463,7 +460,6 @@ declare module 'vue' {
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
@@ -546,7 +542,6 @@ declare module 'vue' {
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toRelativeTime: UnwrapRef<typeof import('./utils/index')['toRelativeTime']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
@@ -634,7 +629,6 @@ declare module 'vue' {
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHistoricalContainerLog: UnwrapRef<typeof import('./composable/historicalLogs')['useHistoricalContainerLog']>
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
+5 -15
View File
@@ -11,6 +11,7 @@ declare module 'vue' {
Announcements: typeof import('./components/Announcements.vue')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
'Carbon:information': typeof import('~icons/carbon/information')['default']
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
@@ -28,7 +29,6 @@ declare module 'vue' {
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
ContainerEventLogItem: typeof import('./components/LogViewer/ContainerEventLogItem.vue')['default']
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
@@ -36,58 +36,49 @@ declare module 'vue' {
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
DateTime: typeof import('./components/common/DateTime.vue')['default']
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
HostList: typeof import('./components/HostList.vue')['default']
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
HostMenu: typeof import('./components/HostMenu.vue')['default']
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
Links: typeof import('./components/Links.vue')['default']
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
LogItem: typeof import('./components/LogViewer/LogItem.vue')['default']
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
'MaterialSymbols:codeBlocksRounded': typeof import('~icons/material-symbols/code-blocks-rounded')['default']
'MaterialSymbols:contentCopy': typeof import('~icons/material-symbols/content-copy')['default']
'MaterialSymbols:eyeTracking': typeof import('~icons/material-symbols/eye-tracking')['default']
'MaterialSymbols:link': typeof import('~icons/material-symbols/link')['default']
'MaterialSymbols:logout': typeof import('~icons/material-symbols/logout')['default']
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
'Mdi:account': typeof import('~icons/mdi/account')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
'Mdi:check': typeof import('~icons/mdi/check')['default']
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default']
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
'Mdi:key': typeof import('~icons/mdi/key')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
@@ -113,7 +104,6 @@ declare module 'vue' {
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
Popup: typeof import('./components/Popup.vue')['default']
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+2 -2
View File
@@ -22,7 +22,7 @@
>
{{ release.name }}
</a>
<span class="ml-1 text-xs"><RelativeTime :date="release.createdAt" /></span>
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
</div>
<div class="text-base-content/80 text-sm">
{{ release.body }}
@@ -39,7 +39,7 @@
>
{{ release.name }}
</a>
<span class="ml-1 text-xs"><RelativeTime :date="release.createdAt" /></span>
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
<Tag class="bg-red ml-auto px-1 py-1 text-xs" v-if="release.latest">
{{ $t("releases.latest") }}
</Tag>
-24
View File
@@ -1,24 +0,0 @@
<template>
<div class="dropdown">
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm"><slot /> <carbon:caret-down /></button>
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm">
<li v-for="other in containers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }" class="text-nowrap">
<div
class="status data-[state=exited]:status-error data-[state=running]:status-success"
:data-state="other.state"
></div>
{{ other.name }}
<div v-if="other.state === 'running'">running</div>
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
</router-link>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { type Container } from "@/models/Container";
const { containers } = defineProps<{
containers: Container[];
}>();
</script>
+24 -28
View File
@@ -1,32 +1,28 @@
<template>
<table class="w-full border-separate border-spacing-x-1">
<tbody>
<tr>
<th class="text-right font-light capitalize">STATE</th>
<td class="font-semibold uppercase">{{ container.state }}</td>
</tr>
<tr v-if="container.startedAt.getFullYear() > 0">
<th class="text-right font-light capitalize">STARTED</th>
<td class="font-semibold">
<RelativeTime :date="container.startedAt" />
</td>
</tr>
<tr v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
<th class="text-right font-light capitalize">FINISHED</th>
<td class="font-semibold">
<RelativeTime :date="container.finishedAt" />
</td>
</tr>
<tr v-if="container.state == 'running'">
<th class="text-right font-light capitalize">Load</th>
<td class="font-semibold">{{ container.stat.cpu.toFixed(2) }}%</td>
</tr>
<tr v-if="container.state == 'running'">
<th class="text-right font-light capitalize">MEM</th>
<td class="font-semibold">{{ formatBytes(container.stat.memoryUsage) }}</td>
</tr>
</tbody>
</table>
<div>
<span class="font-light capitalize"> STATE </span>
<span class="font-semibold uppercase"> {{ container.state }} </span>
</div>
<div v-if="container.startedAt.getFullYear() > 0">
<span class="font-light capitalize"> STARTED </span>
<span class="font-semibold">
<DistanceTime :date="container.startedAt" strict />
</span>
</div>
<div v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
<span class="font-light capitalize"> FINISHED </span>
<span class="font-semibold">
<DistanceTime :date="container.finishedAt" strict />
</span>
</div>
<div v-if="container.state == 'running'">
<span class="font-light capitalize"> Load </span>
<span class="font-semibold"> {{ container.stat.cpu.toFixed(2) }}% </span>
</div>
<div v-if="container.state == 'running'">
<span class="font-light capitalize"> MEM </span>
<span class="font-semibold"> {{ formatBytes(container.stat.memoryUsage) }} </span>
</div>
</template>
<script lang="ts" setup>
+106 -135
View File
@@ -1,120 +1,112 @@
!
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
<input
type="radio"
name="host"
role="tab"
class="tab rounded-sm!"
aria-label="Show All"
v-model="selectedHost"
:value="null"
/>
<input
type="radio"
name="host"
role="tab"
class="tab rounded-sm!"
:aria-label="host.name"
v-for="host in hosts"
:value="host.id"
:key="host.id"
v-model="selectedHost"
/>
</div>
<DropdownMenu
class="btn-sm"
v-model="selectedHost"
:options="[
{ label: 'Show All', value: null },
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
]"
v-else
/>
</div>
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
{{ $t("label.per-page") }}
<DropdownMenu
class="dropdown-left btn-xs md:btn-sm"
v-model="perPage"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
/>
</div>
</div>
<div class="rounded-box border-base-content/10 overflow-x-auto border">
<table class="table-md md:table-lg table-zebra table">
<thead>
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
<th
v-for="(value, key) in fields"
:key="key"
@click.prevent="sort(key)"
:class="{ 'selected-sort': key === sortField }"
v-show="isVisible(key)"
>
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
<span>{{ $t(value.label) }}</span>
<span class="h-4" data-icon>
<mdi:arrow-up />
</span>
</a>
</th>
</tr>
</thead>
<tbody class="bg-base-300/30">
<tr v-for="container in paginated" :key="container.id" class="hover:bg-base-100/80!">
<td v-if="isVisible('name')">
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
{{ container.name }}
</router-link>
</td>
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
<td v-if="isVisible('state')">{{ container.state }}</td>
<td v-if="isVisible('created')">
<RelativeTime :date="container.created" />
</td>
<td v-if="isVisible('cpu')">
<div class="flex flex-row items-center gap-1">
<progress
class="progress h-3 w-full rounded-3xl"
:class="getProgressColorClass(containerAverageCpu(container))"
:value="containerAverageCpu(container)"
max="100"
></progress>
<span class="w-8 text-right text-sm"> {{ containerAverageCpu(container).toFixed(0) }}% </span>
</div>
</td>
<td v-if="isVisible('mem')">
<div class="flex flex-row items-center gap-1">
<progress
class="progress h-3 w-full rounded-3xl"
:class="getProgressColorClass(container.movingAverage.memory)"
:value="container.movingAverage.memory"
max="100"
></progress>
<span class="w-8 text-right text-sm"> {{ container.movingAverage.memory.toFixed(0) }}% </span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="p-4 text-center">
<nav class="join" v-if="isPaginated">
<div class="flex flex-row">
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
<input
class="btn btn-square join-item"
type="radio"
v-model="currentPage"
:aria-label="`${i}`"
:value="i"
v-for="i in totalPages"
name="host"
role="tab"
class="tab rounded-sm!"
aria-label="Show All"
v-model="selectedHost"
:value="null"
/>
</nav>
<input
type="radio"
name="host"
role="tab"
class="tab rounded-sm!"
:aria-label="host.name"
v-for="host in hosts"
:value="host.id"
:key="host.id"
v-model="selectedHost"
/>
</div>
<DropdownMenu
class="btn-sm"
v-model="selectedHost"
:options="[
{ label: 'Show All', value: null },
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
]"
v-else
/>
</div>
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
{{ $t("label.per-page") }}
<DropdownMenu
class="dropdown-left btn-xs md:btn-sm"
v-model="perPage"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
/>
</div>
</div>
<table class="table-lg bg-base-200 table">
<thead>
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
<th
v-for="(value, key) in fields"
:key="key"
@click.prevent="sort(key)"
:class="{ 'selected-sort': key === sortField }"
v-show="isVisible(key)"
>
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
<span>{{ $t(value.label) }}</span>
<span class="h-4" data-icon>
<mdi:arrow-up />
</span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-for="container in paginated" :key="container.id">
<td v-if="isVisible('name')">
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
{{ container.name }}
</router-link>
</td>
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
<td v-if="isVisible('state')">{{ container.state }}</td>
<td v-if="isVisible('created')">
<distance-time :date="container.created" strict :suffix="false"></distance-time>
</td>
<td v-if="isVisible('cpu')">
<div class="flex flex-row items-center gap-1">
<progress
class="progress progress-primary"
:value="Math.min(container.movingAverage.cpu, 100)"
:max="100"
></progress>
<span class="text-sm">{{ container.movingAverage.cpu.toFixed(0) }}%</span>
</div>
</td>
<td v-if="isVisible('mem')">
<div class="flex flex-row items-center gap-1">
<progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress>
<span class="text-sm">{{ container.movingAverage.memory.toFixed(0) }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
<div class="p-4 text-center">
<nav class="join" v-if="isPaginated">
<input
class="btn btn-square join-item"
type="radio"
v-model="currentPage"
:aria-label="`${i}`"
:value="i"
v-for="i in totalPages"
/>
</nav>
</div>
</template>
@@ -202,31 +194,10 @@ function sort(field: keys) {
function isVisible(field: keys) {
return fields[field].mobileVisible || !isMobile.value;
}
function getContainerCores(container: Container): number {
if (container.cpuLimit && container.cpuLimit > 0) {
return container.cpuLimit;
}
const hostInfo = hosts.value[container.host];
return hostInfo?.nCPU ?? 1;
}
function containerAverageCpu(container: Container): number {
const cores = getContainerCores(container);
const scaledCpu = container.movingAverage.cpu / cores;
return Math.min(scaledCpu, 100);
}
function getProgressColorClass(value: number): string {
if (value <= 70) return "progress-success";
if (value <= 80) return "progress-secondary";
if (value <= 90) return "progress-warning";
return "progress-error";
}
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
[data-icon] {
display: none;
@@ -237,7 +208,7 @@ function getProgressColorClass(value: number): string {
}
th {
@apply border-base-200 border-b-2;
@apply border-base-100 border-b-2;
&.selected-sort {
font-weight: bold;
@apply border-primary;
@@ -1,16 +1,12 @@
<template>
<div class="dropdown dropdown-end dropdown-hover z-20">
<div class="dropdown dropdown-end dropdown-hover">
<label tabindex="0" class="btn btn-ghost btn-sm w-10 gap-0.5 px-2">
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
</label>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
@click="hideMenu"
>
<li v-if="!historical">
<a @click="clear()">
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
<li>
<a @click.prevent="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
@@ -18,14 +14,14 @@
<li>
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li v-if="!historical">
<a @click="showSearch = true">
<li>
<a @click.prevent="showSearch = true">
<mdi:magnify /> {{ $t("toolbar.search") }}
<KeyShortcut char="f" />
</a>
</li>
<li v-if="hasComplexLogs">
<a @click="showDrawer(LogAnalytics, { container }, 'lg')">
<a @click.prevent="showDrawer(LogAnalytics, { container }, 'lg')">
<ph:file-sql /> SQL Analytics
<KeyShortcut char="f" :modifiers="['shift', 'meta']" />
</a>
@@ -107,7 +103,7 @@
</li>
<!-- Container Actions (Enabled via config) -->
<template v-if="enableActions && !historical">
<template v-if="enableActions">
<li class="line"></li>
<li>
<button
@@ -139,23 +135,19 @@
</li>
</template>
<template v-if="enableShell && !historical">
<li class="line"></li>
<li>
<a @click="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
<ri:terminal-window-fill />
{{ $t("toolbar.attach") }}
<KeyShortcut char="a" :modifiers="['shift', 'meta']" />
</a>
</li>
<li>
<a @click="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
<material-symbols:terminal />
{{ $t("toolbar.shell") }}
<KeyShortcut char="e" :modifiers="['shift', 'meta']" />
</a>
</li>
</template>
<li class="line"></li>
<li>
<a @click.prevent="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
<ri:terminal-window-fill /> Attach
<KeyShortcut char="a" :modifiers="['shift', 'meta']" />
</a>
</li>
<li>
<a @click.prevent="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
<material-symbols:terminal /> Shell
<KeyShortcut char="e" :modifiers="['shift', 'meta']" />
</a>
</li>
</ul>
</div>
</template>
@@ -167,11 +159,11 @@ import LogAnalytics from "../LogViewer/LogAnalytics.vue";
import Terminal from "@/components/Terminal.vue";
const { showSearch } = useSearchFilter();
const { enableActions, enableShell } = config;
const { enableActions } = config;
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
const showDrawer = useDrawer();
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
const { container } = defineProps<{ container: Container }>();
const clear = defineEmit();
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
@@ -183,21 +175,20 @@ onKeyStroke("f", (e) => {
}
}
});
if (enableShell) {
onKeyStroke("a", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(Terminal, { container, action: "attach" }, "lg");
e.preventDefault();
}
});
onKeyStroke("e", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(Terminal, { container, action: "exec" }, "lg");
e.preventDefault();
}
});
}
onKeyStroke("a", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(Terminal, { container, action: "attach" }, "lg");
e.preventDefault();
}
});
onKeyStroke("e", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(Terminal, { container, action: "exec" }, "lg");
e.preventDefault();
}
});
const downloadParams = computed(() =>
Object.entries(toValue(streamConfig))
@@ -223,20 +214,10 @@ const toggleAllLevels = computed({
}
},
});
const hideMenu = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) {
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}, 50);
}
};
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
li.line {
@apply bg-base-content/20 h-px;
@@ -15,7 +15,7 @@ defineProps<{
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
[health="unhealthy"] {
@apply text-red;
}
@@ -1,48 +1,44 @@
<template>
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
<div class="@container flex flex-1 items-center gap-1.5 truncate md:gap-2">
<label class="swap swap-rotate size-4">
<input type="checkbox" v-model="pinned" />
<carbon:star-filled class="swap-on text-secondary" />
<carbon:star class="swap-off" />
</label>
<div class="inline-flex items-center text-sm">
<div class="breadcrumbs overflow-x-visible p-0 font-mono">
<div class="breadcrumbs p-0">
<ul>
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
{{ container.hostLabel }}
</li>
<li>
<template v-if="otherContainers.length === 0">{{ container.name }}</template>
<div v-else>
<div class="dropdown">
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
{{ container.name }} <carbon:caret-down />
</button>
<ul
tabindex="0"
class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm"
>
<li v-for="other in otherContainers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
<div
class="status data-[state=exited]:status-error data-[state=running]:status-success"
:data-state="other.state"
></div>
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
<div v-else>{{ other.name }}</div>
<div v-if="other.state === 'running'">running</div>
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
</router-link>
</li>
</ul>
</div>
<div v-if="otherContainers.length === 0" class="font-mono">{{ container.name }}</div>
<div class="wrapper" ref="wrapper" v-else>
<button popovertarget="popover-container-list" class="btn btn-xs md:btn-sm anchor font-mono">
{{ container.name }} <carbon:caret-down />
</button>
<ul popover id="popover-container-list" class="dropdown menu rounded-box bg-base-100 tethered shadow-sm">
<li v-for="other in otherContainers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
<div
class="status data-[state=exited]:status-error data-[state=running]:status-success"
:data-state="other.state"
></div>
<div class="font-mono" v-if="other.isSwarm">{{ other.swarmId }}</div>
<div class="font-mono" v-else>{{ other.name }}</div>
<div v-if="other.state === 'running'">running</div>
<DistanceTime :date="other.created" strict class="text-base-content/70 text-xs" v-else />
</router-link>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
<ContainerHealth :health="container.health" v-if="container.health" />
<Tag class="hidden! font-mono @xl:block!" size="small">
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
{{ container.image.replace(/@sha.*/, "") }}
</Tag>
</div>
@@ -70,6 +66,34 @@ const otherContainers = computed(() =>
.filter((c) => c.name === container.name && c.id !== container.id)
.sort((a, b) => +b.created - +a.created),
);
const wrapper = useTemplateRef("wrapper");
onMounted(async () => {
if (!("anchorName" in document.documentElement.style)) {
// @ts-ignore
const module = await import("@oddbird/css-anchor-positioning/fn");
// @ts-ignore
await module.default([wrapper.value]);
}
});
</script>
<style scoped></style>
<style scoped>
/* https://github.com/oddbird/css-anchor-positioning/issues/282 */
.wrapper {
anchor-scope: --anchor;
}
.anchor {
anchor-name: --anchor;
}
.tethered {
margin: 0;
padding: 0;
position-anchor: --anchor;
position: absolute;
top: anchor(bottom);
left: anchor(left);
}
</style>
@@ -1,63 +0,0 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="container">
<template #header v-if="showTitle">
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
<ContainerTitle :container="container" />
<router-link
:to="{ name: '/container/[id]', params: { id: container.id } }"
class="btn btn-secondary btn-sm"
v-if="container.state === 'running'"
>
<mdi:lightning-bolt />
Live Logs
</router-link>
<ContainerActionsToolbar class="max-md:hidden" :container="container" historical />
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
<mdi:close />
</a>
</div>
</template>
<template #default>
<ViewerWithSource
ref="viewer"
:stream-source="useHistoricalContainerLog"
:entity="historicalContainer"
:visible-keys="visibleKeys"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
import { HistoricalContainer } from "@/models/Container";
import { ComponentExposed } from "vue-component-type-helpers";
const {
id,
showTitle = false,
scrollable = false,
closable = false,
date,
} = defineProps<{
id: string;
showTitle?: boolean;
scrollable?: boolean;
closable?: boolean;
date: Date;
}>();
const close = defineEmit();
const store = useContainerStore();
const container = store.currentContainer(toRef(() => id));
const historicalContainer = toRef(() => new HistoricalContainer(container.value, date));
const visibleKeys = persistentVisibleKeysForContainer(container);
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
provideLoggingContext(
toRef(() => [container.value]),
{ showContainerName: false, showHostname: false, historical: true },
);
</script>
@@ -42,8 +42,6 @@ function createFuzzySearchModal() {
"host",
{},
"running",
0,
0,
[],
),
new Container(
@@ -57,8 +55,6 @@ function createFuzzySearchModal() {
"host",
{},
"running",
0,
0,
[],
),
new Container(
@@ -72,8 +68,6 @@ function createFuzzySearchModal() {
"host",
{},
"running",
0,
0,
[],
),
],
+6 -9
View File
@@ -1,5 +1,5 @@
<template>
<div class="dropdown dropdown-open w-full shadow-md">
<div class="dropdown dropdown-open w-full">
<div class="input input-xl input-primary flex w-full items-center">
<mdi:magnify class="flex size-8" />
<input
@@ -13,11 +13,8 @@
v-model="query"
:placeholder="$t('placeholder.search-containers')"
/>
<form method="dialog" class="flex">
<button v-if="isMobile">
<mdi:close />
</button>
<button v-else class="swap hover:swap-active outline-hidden">
<form method="dialog">
<button class="swap hover:swap-active">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
@@ -33,7 +30,7 @@
<a
class="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
@click.prevent="selected(result.item)"
:class="{ 'menu-focus': index === selectedIndex }"
:class="index === selectedIndex ? 'menu-focus' : ''"
>
<div :class="{ 'text-primary': result.item.state === 'running' }">
<template v-if="result.item.type === 'container'">
@@ -53,7 +50,7 @@
<span data-name v-html="matchedName(result)"></span>
</div>
<RelativeTime :date="result.item.created" class="text-xs font-light" />
<DistanceTime :date="result.item.created" class="text-xs font-light" />
<span
@click.stop.prevent="addColumn(result.item)"
:title="$t('tooltip.pin-column')"
@@ -217,7 +214,7 @@ function matchedName({ item, matches = [] }: FuseResult<Item>) {
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
:deep(mark) {
@apply bg-transparent text-inherit underline underline-offset-2;
}
@@ -2,9 +2,11 @@
<ScrollableView :scrollable="scrollable" v-if="group.containers.length && ready">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<ContainerDropdown :containers="group.containers">
{{ $t("label.container", group.containers.length) }}
</ContainerDropdown>
<div class="@container flex flex-1 gap-1.5 truncate md:gap-2">
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ $t("label.container", group.containers.length) }}</div>
</div>
</div>
<MultiContainerStat class="ml-auto" :containers="group.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
+1 -1
View File
@@ -51,7 +51,7 @@
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalMem / host.memTotal) * 100)};`"
role="progressbar"
>
{{ formatBytes(weightedStats[host.id].weighted.totalMem, { decimals: 1, short: true }) }}
{{ formatBytes(weightedStats[host.id].weighted.totalMem, 1) }}
</div>
</div>
</div>
+4 -22
View File
@@ -24,21 +24,15 @@
<div class="flex-none">
<div class="dropdown dropdown-end dropdown-hover">
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
<ion:ellipsis-vertical />
<ph:dots-three-vertical-bold />
</label>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
<li>
<a class="text-sm capitalize" @click="toggleShowAllContainers()">
<mdi:check class="w-4" v-if="showAllContainers" />
<div v-else class="w-4"></div>
{{ $t("label.show-all-containers") }}
</a>
<a class="text-sm capitalize" @click="collapseAll()">
<material-symbols-light:collapse-all class="w-4" />
{{ $t("label.collapse-all") }}
Show all containers
</a>
</li>
</ul>
@@ -147,18 +141,6 @@ const updateCollapsedGroups = (event: Event, label: string) => {
} else {
collapsedGroups.value.add(label);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const collapseAll = () => {
menuItems.value.forEach(({ label }) => {
collapsedGroups.value.add(label);
});
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const debouncedPinnedContainers = debouncedRef(pinnedContainers, 200);
+1 -1
View File
@@ -2,7 +2,7 @@
<ScrollableView :scrollable="scrollable" v-if="host">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
<div class="flex flex-1 gap-1.5 truncate md:gap-2">
<ph:computer-tower />
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ host.name }}</div>
+31
View File
@@ -0,0 +1,31 @@
<template>
<div ref="root" class="flex min-h-[1px] justify-center">
<span class="loading loading-bars loading-md text-primary mt-4" v-show="isLoading"></span>
</div>
</template>
<script lang="ts" setup>
const { onLoadMore = () => Promise.resolve(), enabled } = defineProps<{
onLoadMore: () => Promise<void>;
enabled: boolean;
}>();
const isLoading = ref(false);
const root = ref<HTMLElement>();
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].intersectionRatio <= 0) return;
if (onLoadMore && enabled) {
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
const previousHeight = scrollingParent.scrollHeight;
isLoading.value = true;
await onLoadMore();
isLoading.value = false;
await nextTick();
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
}
});
onMounted(() => observer.observe(root.value!));
onUnmounted(() => observer.disconnect());
</script>
+1 -4
View File
@@ -30,10 +30,7 @@
</div>
<ul class="menu mt-4 p-0">
<li v-if="config.authProvider === 'simple'">
<button @click.prevent="logout()" class="text-primary p-2">
<material-symbols:logout />
{{ $t("button.logout") }}
</button>
<button @click.prevent="logout()" class="text-primary p-2">{{ $t("button.logout") }}</button>
</li>
</ul>
</template>
+12 -49
View File
@@ -1,31 +1,15 @@
<template>
<DefineTemplate v-slot="{ data }">
<ul class="inline-flex flex-wrap space-x-4" @click="preventDefaultOnLinks">
<li v-for="(value, name) in data" :key="name" v-if="isObject(data)">
<span class="key">{{ name }}=</span>
<span class="value" v-if="value === null">&lt;null&gt;</span>
<ReuseTemplate :data="value" v-else-if="isObject(value) || Array.isArray(value)" />
<span v-else class="value" :class="typeof value" v-html="stripAnsi(value.toString())"></span>
<LogItem :logEntry @click="showDrawer(LogDetails, { entry: logEntry })" class="clickable">
<ul class="space-x-4">
<li v-for="(value, name) in validValues" :key="name" class="inline-flex">
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null">&lt;null&gt;</span>
<template v-else-if="Array.isArray(value)">
<span class="font-bold" v-html="JSON.stringify(value)"> </span>
</template>
<span class="font-bold" v-html="stripAnsi(value.toString())" v-else></span>
</li>
<li v-else-if="Array.isArray(data)">
<ul class="array inline-flex flex-wrap space-x-1">
<li
v-for="(item, index) in data"
:key="index"
class="after:text-base-content/70 not-last:after:content-[',']"
>
<ReuseTemplate :data="item" v-if="isObject(item) || Array.isArray(item)" />
<span v-else class="value" :class="typeof item" v-html="stripAnsi(item.toString())"></span>
</li>
</ul>
</li>
<li class="key" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
</ul>
</DefineTemplate>
<LogItem :logEntry>
<div @click="containers.length > 0 && showDrawer(LogDetails, { entry: logEntry })" class="cursor-pointer">
<ReuseTemplate :data="validValues" />
</div>
</LogItem>
</template>
<script lang="ts" setup>
@@ -38,37 +22,16 @@ const { logEntry } = defineProps<{
showContainerName?: boolean;
}>();
const { containers } = useLoggingContext();
const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
const validValues = computed(() => {
return Object.fromEntries(Object.entries(logEntry.message).filter(([_, value]) => value !== undefined));
});
const showDrawer = useDrawer();
function preventDefaultOnLinks(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.rel?.includes("external")) {
event.stopImmediatePropagation();
}
}
</script>
<style scoped>
@reference "@/main.css";
.key {
@apply text-base-content/70 font-light;
}
.value {
@apply text-base-content font-bold;
}
.array {
@apply before:text-base-content/80 after:text-base-content/80 before:content-['['] after:content-[']'];
}
.string {
@apply before:content-['"'] after:content-['"'];
@import "@/main.css" reference;
.text-light {
@apply text-base-content/70;
}
</style>
@@ -13,7 +13,7 @@ const { logEntry } = defineProps<{
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
[data-event="container-stopped"] {
@apply text-red;
}
@@ -65,29 +65,12 @@ describe("<ContainerEventSource />", () => {
template: "Test from createLogEventSource",
},
},
{
name: "/container/[id].time.[datetime]",
path: "/container/:id/time/:datetime",
component: {
template: "Test from createLogEventSource",
},
},
],
});
return mount(Component, {
global: {
plugins: [
router,
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: {
container: { containers: [{ id: "abc", image: "test:v123", host: "localhost" }] },
},
}),
createI18n({}),
],
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
components: {
LogViewer,
},
@@ -101,7 +84,6 @@ describe("<ContainerEventSource />", () => {
streamConfig: reactive({ stdout: true, stderr: true }),
hasComplexLogs: ref(false),
levels: new Set<Level>(["info"]),
historical: ref(false),
},
},
},
@@ -123,8 +105,6 @@ describe("<ContainerEventSource />", () => {
"localhost",
{},
"created",
0,
0,
[],
),
},
@@ -156,7 +136,7 @@ describe("<ContainerEventSource />", () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
});
vi.runAllTimers();
@@ -172,39 +152,39 @@ describe("<ContainerEventSource />", () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
});
vi.runAllTimers();
await nextTick();
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
});
test("should render dates with 12 hour style", async () => {
const wrapper = createLogEventSource({ hourStyle: "12" });
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "rm": "foo bar", "c": "abc"}`,
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
});
vi.runAllTimers();
await nextTick();
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
});
test("should render dates with 24 hour style", async () => {
const wrapper = createLogEventSource({ hourStyle: "24" });
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "c": "abc"}`,
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
});
vi.runAllTimers();
await nextTick();
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
});
});
});
+18 -39
View File
@@ -1,4 +1,5 @@
<template>
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
<div class="flex flex-row gap-2" v-for="size in sizes">
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
@@ -6,26 +7,21 @@
</div>
<span class="sr-only">Loading...</span>
</ul>
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">
{{ $t("label.no-logs") }}
</div>
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">Container has no logs yet</div>
<slot :messages="messages" v-else></slot>
<IndeterminateBar :color v-if="!historical" />
<IndeterminateBar :color />
</template>
<script lang="ts" setup generic="T">
import { LogStreamSource } from "@/composable/eventStreams";
const route = useRoute();
const { entity, streamSource } = $defineProps<{
streamSource: (t: Ref<T>) => LogStreamSource;
entity: T;
}>();
const { historical } = useLoggingContext();
const { messages, opened, loading, error } = streamSource(toRef(() => entity));
const { messages, loadOlderLogs, isLoadingMore, opened, loading, error, eventSourceURL } = streamSource($$(entity));
const { loadingMore } = useLoggingContext();
const color = computed(() => {
if (error.value) return "error";
if (loading.value) return "secondary";
@@ -41,36 +37,19 @@ defineExpose({
clear: () => (messages.value = []),
});
if (historical.value && route.query.logId) {
watchOnce(messages, async () => {
await nextTick();
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" });
});
}
const fetchMore = async () => {
if (!isLoadingMore.value) {
loadingMore.value = true;
await loadOlderLogs();
loadingMore.value = false;
}
};
const sizes = ref<string[]>([]);
watch(
opened,
(value) => {
if (value) return;
const sizeOptions = [
"w-2/12",
"w-3/12",
"w-4/12",
"w-5/12",
"w-6/12",
"w-7/12",
"w-8/12",
"w-9/12",
"w-10/12",
"w-11/12",
"w-full",
];
sizes.value = Array.from({ length: 18 }, () => sizeOptions[Math.floor(Math.random() * sizeOptions.length)]);
},
{
flush: "sync",
immediate: true,
},
const shuffle = (items: any[]) => {
return items.sort(() => Math.random() - 0.5);
};
const sizes = computedWithControl(eventSourceURL, () =>
shuffle(["w-3/5", "w-2/3", "w-9/12", "w-1/2", "w-1/3", "w-3/4"]),
);
</script>
@@ -1,31 +0,0 @@
<template>
<div ref="root" class="flex min-h-[1px] flex-1 content-center justify-center">
<span class="loading loading-bars loading-md text-primary m-2" v-show="isLoading"></span>
</div>
</template>
<script lang="ts" setup>
import { LoadMoreLogEntry } from "@/models/LogEntry";
const { logEntry } = defineProps<{
logEntry: LoadMoreLogEntry;
}>();
const isLoading = ref(false);
const root = ref<HTMLElement>();
useIntersectionObserver(root, async (entries) => {
if (entries[0].intersectionRatio <= 0) return;
if (isLoading.value) return;
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
const previousHeight = scrollingParent.scrollHeight;
isLoading.value = true;
await logEntry.loadMore();
isLoading.value = false;
await nextTick();
if (logEntry.rememberScrollPosition) {
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
}
});
</script>
<style scoped></style>
-133
View File
@@ -1,133 +0,0 @@
<template>
<div class="dropdown dropdown-start dropdown-hover absolute -left-2 z-10 font-sans" v-show="container">
<router-link
v-if="isSearching"
@click="resetSearch()"
tabindex="0"
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
:to="{
name: '/container/[id].time.[datetime]',
params: { id: container.id, datetime: logEntry.date.toISOString() },
query: { logId: logEntry.id },
}"
>
<material-symbols:eye-tracking />
</router-link>
<button
tabindex="0"
class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"
v-else
>
<ion:ellipsis-vertical />
</button>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
@click="hideMenu"
>
<li v-if="isSearching">
<router-link
@click="resetSearch()"
:to="{
name: '/container/[id].time.[datetime]',
params: { id: container.id, datetime: logEntry.date.toISOString() },
query: { logId: logEntry.id },
}"
>
<material-symbols:eye-tracking />
{{ $t("action.see-in-context") }}
</router-link>
</li>
<li v-if="isSupported">
<a @click="copyLogMessage()">
<material-symbols:content-copy />
{{ $t("action.copy-log") }}
</a>
</li>
<li v-if="isSupported">
<a @click="copyPermalink()">
<material-symbols:link />
{{ $t("action.copy-link") }}
</a>
</li>
<li v-if="logEntry instanceof ComplexLogEntry">
<a @click="showDrawer(LogDetails, { entry: logEntry })">
<material-symbols:code-blocks-rounded />
{{ $t("action.show-details") }}
</a>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import stripAnsi from "strip-ansi";
import { Container } from "@/models/Container";
import { LogEntry, SimpleLogEntry, ComplexLogEntry, JSONObject } from "@/models/LogEntry";
import LogDetails from "./LogDetails.vue";
const { logEntry, container } = defineProps<{
logEntry: LogEntry<string | JSONObject>;
container: Container;
}>();
const { showToast } = useToast();
const showDrawer = useDrawer();
const router = useRouter();
const { isSearching, resetSearch } = useSearchFilter();
const { copy, isSupported, copied } = useClipboard();
const { t } = useI18n();
async function copyLogMessage() {
if (logEntry instanceof ComplexLogEntry) {
await copy(stripAnsi(logEntry.rawMessage));
} else if (logEntry instanceof SimpleLogEntry) {
await copy(stripAnsi(logEntry.rawMessage));
}
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
async function copyPermalink() {
const url = router.resolve({
name: "/container/[id].time.[datetime]",
params: { id: container.id, datetime: logEntry.date.toISOString() },
query: { logId: logEntry.id },
}).href;
const resolved = new URL(url, window.location.origin);
await copy(resolved.href);
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
function hideMenu(e: MouseEvent) {
if (e.target instanceof HTMLAnchorElement) {
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}, 50);
}
}
</script>
+24 -60
View File
@@ -2,7 +2,7 @@
<aside>
<header class="flex items-center gap-4">
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
<h2 class="text-sm"><RelativeTime :date="container.created" /></h2>
<h2 class="text-sm"><DistanceTime :date="container.created" /></h2>
</header>
<div class="mt-8 flex flex-col gap-2">
@@ -11,26 +11,18 @@
<textarea
v-model="query"
class="textarea textarea-primary w-full font-mono text-lg"
:class="{ 'textarea-error!': error }"
:disabled="state === 'downloading'"
:class="{ 'textarea-error': error }"
></textarea>
<div class="mt-2">
<span class="text-error" v-if="error">{{ error }}</span>
<span v-else-if="state === 'initializing'">{{ $t("analytics.creating_table") }}</span>
<span v-else-if="state === 'downloading'">{{
$t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) })
}}</span>
<span v-else-if="evaluating">{{ $t("analytics.evaluating_query") }}</span>
<span v-else>
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
<template v-if="results.numRows > pageLimit">{{
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
}}</template>
<div class="label max-h-48 overflow-y-auto pr-2">
<span class="label-text-alt text-error" v-if="error">{{ error }}</span>
<span class="label-text-alt" v-else>
Total {{ results.numRows }} records
<template v-if="results.numRows > pageLimit"> . Showing first {{ page.numRows }}. </template>
</span>
</div>
</label>
</section>
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
<SQLTable :table="page" :loading="evaluating || !isReady" />
</div>
</aside>
</template>
@@ -38,15 +30,12 @@
<script setup lang="ts">
import { Container } from "@/models/Container";
import { type Table } from "@apache-arrow/esnext-esm";
const { container } = defineProps<{ container: Container }>();
const query = ref("SELECT * FROM logs LIMIT 100");
const error = ref<string | null>(null);
const debouncedQuery = debouncedRef(query, 500);
const evaluating = ref(false);
const pageLimit = 1000;
const state = ref<"downloading" | "ready" | "initializing">("downloading");
const bytes = ref(0);
const url = withBase(
`/api/hosts/${container.host}/containers/${container.id}/logs?stdout=1&stderr=1&everything&jsonOnly`,
@@ -62,49 +51,27 @@ if (!response.ok) {
const { db, conn } = await useDuckDB();
const empty = await conn.query<Record<string, any>>(`SELECT 1 LIMIT 0`);
onMounted(async () => {
try {
state.value = "downloading";
const reader = response.body?.getReader();
if (!reader) throw new Error("No reader available from stream");
const chunks: Uint8Array[] = [];
bytes.value = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
bytes.value += value.length;
}
const arrayBuffer = new Uint8Array(bytes.value);
let position = 0;
for (const chunk of chunks) {
arrayBuffer.set(chunk, position);
position += chunk.length;
}
await db.registerFileBuffer("logs.json", arrayBuffer);
state.value = "initializing";
const { isReady } = useAsyncState(
async () => {
await db.registerFileBuffer("logs.json", new Uint8Array(await response.arrayBuffer()));
await conn.query(
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited')`,
);
state.value = "ready";
} catch (e) {
console.error(e);
if (e instanceof Error) {
error.value = e.message;
}
}
});
},
undefined,
{
onError: (e) => {
console.error(e);
if (e instanceof Error) {
error.value = e.message;
}
},
},
);
const results = computedAsync(
async () => {
if (state.value === "ready") {
if (isReady.value) {
return await conn.query<Record<string, any>>(debouncedQuery.value);
} else {
return empty;
@@ -122,10 +89,7 @@ const results = computedAsync(
},
);
whenever(evaluating, () => {
error.value = null;
state.value = "ready";
});
whenever(evaluating, () => (error.value = null));
const page = computed(() =>
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
) as unknown as ComputedRef<Table<Record<string, any>>>;
+11 -12
View File
@@ -1,10 +1,10 @@
<template>
<header class="flex items-center gap-4">
<Tag :data-level="entry.level" class="show-unknown text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
<Tag :data-level="entry.level" class="text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
<h1 class="text-lg max-md:hidden">
<DateTime :date="entry.date" />
</h1>
<h2 class="text-sm"><RelativeTime :date="entry.date" /> on {{ entry.std }}</h2>
<h2 class="text-sm"><DistanceTime :date="entry.date" /> on {{ entry.std }}</h2>
</header>
<div class="mt-8 flex flex-col gap-10">
@@ -29,15 +29,15 @@
<div class="flex gap-2">
Raw JSON
<UseClipboard v-slot="{ copy, copied }" :source="entry.rawMessage">
<UseClipboard v-slot="{ copy, copied }" :source="JSON.stringify(entry.unfilteredMessage)">
<button class="swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
<mdi:check class="swap-on" />
<material-symbols:content-copy class="swap-off" />
<mdi:content-copy class="swap-off" />
</button>
</UseClipboard>
</div>
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
<pre v-html="syntaxHighlight(entry.unfilteredMessage)"></pre>
</div>
</section>
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
@@ -59,7 +59,7 @@
{{ key.join(".") }}
</td>
<td class="truncate max-md:hidden">
<code>{{ JSON.stringify(value) }}</code>
<code v-html="JSON.stringify(value)"></code>
</td>
<td>
<input type="checkbox" class="toggle toggle-primary" :checked="enabled" @change="toggleField(key)" />
@@ -96,15 +96,14 @@ function toggleField(key: string[]) {
const fields = computed({
get() {
const fieldsWithValue: { key: string[]; value: any; enabled: boolean }[] = [];
const rawFields = JSON.parse(entry.rawMessage);
const allFields = flattenJSONToMap(rawFields);
const allFields = flattenJSONToMap(entry.unfilteredMessage);
if (visibleKeys.value.size === 0) {
for (const [key, value] of allFields) {
fieldsWithValue.push({ key, value, enabled: true });
}
} else {
for (const [key, enabled] of visibleKeys.value) {
const value = getDeep(rawFields, key);
const value = getDeep(entry.unfilteredMessage, key);
fieldsWithValue.push({ key, value, enabled });
}
@@ -142,8 +141,8 @@ const toggleAllFields = computed({
},
});
function syntaxHighlight(json: string) {
json = JSON.stringify(JSON.parse(json.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")), null, 2);
function syntaxHighlight(json: any) {
json = JSON.stringify(json, null, 2);
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
function (match: string) {
@@ -167,7 +166,7 @@ function syntaxHighlight(json: string) {
useSortable(list, fields);
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
.font-mono {
font-family:
ui-monospace,
+3 -20
View File
@@ -1,24 +1,9 @@
<template>
<div class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<LogActions :logEntry :container />
<LogStd :std="logEntry.std" class="shrink-0 select-none" v-if="showStd" />
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<RandomColorTag class="w-30 shrink-0 select-none md:w-40" :value="host.name" v-if="showHostname" />
<RandomColorTag
v-if="showContainerName"
class="w-30 shrink-0 select-none group-[.compact]:flex-1 md:w-40"
:value="container.name"
truncateRight
/>
<LogDate
v-if="showTimestamp"
:date="logEntry.date"
class="shrink-0 select-none"
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
/>
</div>
<RandomColorTag class="shrink-0 select-none" :value="host.name" v-if="showHostname" />
<RandomColorTag class="shrink-0 select-none" :value="container.name" v-if="showContainerName" truncateRight />
<LogDate :date="logEntry.date" v-if="showTimestamp" class="shrink-0 select-none" />
<LogLevel
class="flex select-none"
:level="logEntry.level"
@@ -40,6 +25,4 @@ const { hosts } = useHosts();
const container = currentContainer(toRef(() => logEntry.containerID));
const host = computed(() => hosts.value[container.value.host]);
const route = useRoute();
</script>
+6 -20
View File
@@ -1,27 +1,17 @@
<template>
<div
:data-level="level"
:data-position="position"
class="mt-1.5 size-2.5 flex-none rounded-lg"
:class="{ showUnknown }"
></div>
<div :data-level="level" :data-position="position" class="mt-1.5 size-2.5 flex-none rounded-lg"></div>
</template>
<script lang="ts" setup>
import { Position, Level } from "@/models/LogEntry";
import { Position } from "@/models/LogEntry";
const {
level,
position,
showUnknown = false,
} = defineProps<{
level?: Level;
defineProps<{
level?: string;
position?: Position;
showUnknown?: boolean;
}>();
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
[data-position="start"] {
border-radius: 0.5em 0.5em 0 0;
height: 70%;
@@ -45,7 +35,7 @@ const {
}
</style>
<style>
@reference "@/main.css";
@import "@/main.css" reference;
[data-level="debug"],
[data-level="trace"] {
@apply !bg-purple;
@@ -63,8 +53,4 @@ const {
[data-level="warn"] {
@apply !bg-orange;
}
[data-level="unknown"].show-unknown {
@apply !bg-base-300;
}
</style>
+10 -14
View File
@@ -1,10 +1,10 @@
<template>
<ul class="group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }" data-logs>
<ul class="events group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
<li
v-for="item in messages"
ref="list"
:key="item.id"
:id="item.id.toString()"
:data-key="item.id"
:data-time="item.date.getTime()"
class="group/entry"
>
@@ -16,17 +16,20 @@
<script lang="ts" setup>
import { type JSONObject, LogEntry } from "@/models/LogEntry";
const { progress, currentDate } = useScrollContext();
const { loading, progress, currentDate } = useScrollContext();
const { messages } = defineProps<{
messages: LogEntry<string | JSONObject>[];
}>();
watchEffect(() => {
loading.value = messages.length === 0;
});
const { containers } = useLoggingContext();
const list = ref<HTMLElement[]>([]);
let previousDate = new Date();
useIntersectionObserver(
list,
(entries) => {
@@ -37,12 +40,9 @@ useIntersectionObserver(
const time = entry.target.getAttribute("data-time");
if (time) {
const date = new Date(parseInt(time));
if (+date === +previousDate) break;
previousDate = date;
const diff = new Date().getTime() - container.created.getTime();
progress.value = (date.getTime() - container.created.getTime()) / diff;
currentDate.value = date;
break;
}
}
}
@@ -54,8 +54,8 @@ useIntersectionObserver(
);
</script>
<style scoped>
@reference "@/main.css";
ul {
@import "@/main.css" reference;
.events {
font-family:
ui-monospace,
SFMono-Regular,
@@ -67,7 +67,7 @@ ul {
monospace;
> li {
@apply flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] md:px-4;
@apply has-[.clickable]:hover:bg-primary/10 flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] has-[.clickable]:cursor-pointer md:px-4;
&:last-child {
scroll-margin-block-end: 5rem;
}
@@ -99,10 +99,6 @@ ul {
@apply bg-secondary inline-block rounded-xs;
animation: pops 200ms ease-out;
}
:deep(a[rel~="external"]) {
@apply text-primary underline-offset-4 hover:underline;
}
}
@keyframes pops {
@@ -0,0 +1,44 @@
<template>
<div class="flex gap-2">
<div
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
v-if="isSupported"
:title="t('log_actions.copy_log')"
>
<span
class="text-primary rounded-sm bg-slate-800/60 px-1.5 py-1 hover:bg-slate-700"
@click.prevent="copyLogMessageToClipBoard()"
>
<carbon:copy-file />
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { LogEntry, JSONObject } from "@/models/LogEntry";
const { message } = defineProps<{
message: () => string;
logEntry: LogEntry<string | JSONObject>;
}>();
const { showToast } = useToast();
const { copy, isSupported, copied } = useClipboard();
const { t } = useI18n();
async function copyLogMessageToClipBoard() {
await copy(message());
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
</script>
+1 -1
View File
@@ -13,7 +13,7 @@ defineProps<{
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
[std="stdout"] {
@apply text-blue;
}
+24
View File
@@ -13,6 +13,30 @@ const props = defineProps<{
const { messages, visibleKeys } = toRefs(props);
const { filteredPayload } = useVisibleFilter(visibleKeys);
const { debouncedSearchFilter } = useSearchFilter();
const { streamConfig } = useLoggingContext();
const visibleMessages = filteredPayload(messages);
const router = useRouter();
watchEffect(() => {
const query = {} as Record<string, string>;
if (debouncedSearchFilter.value !== "") {
query.search = debouncedSearchFilter.value;
}
if (!streamConfig.value.stderr) {
query.stderr = streamConfig.value.stderr.toString();
}
if (!streamConfig.value.stdout) {
query.stdout = streamConfig.value.stdout.toString();
}
router.push({
query,
replace: true,
});
});
</script>
<style scoped></style>
@@ -1,16 +1,12 @@
<template>
<div class="dropdown dropdown-end dropdown-hover z-20">
<div class="dropdown dropdown-end dropdown-hover">
<label tabindex="0" class="btn btn-ghost btn-sm gap-0.5 px-2">
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
</label>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
@click="hideMenu"
>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
<li>
<a @click="clear()">
<a @click.prevent="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
@@ -19,7 +15,7 @@
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li>
<a @click="showSearch = true">
<a @click.prevent="showSearch = true">
<mdi:magnify /> {{ $t("toolbar.search") }}
<KeyShortcut char="f" />
</a>
@@ -104,20 +100,10 @@ const downloadUrl = computed(() =>
`/api/containers/${containers.value.map((c) => c.host + "~" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
),
);
const hideMenu = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) {
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}, 50);
}
};
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
li.line {
@apply bg-base-content/20 h-px;
}
@@ -1,17 +1,7 @@
<template>
<div class="flex gap-4">
<StatMonitor
:data="memoryData"
label="mem"
:stat-value="formatBytes(totalStat.memoryUsage)"
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
/>
<StatMonitor
:data="cpuData"
label="load"
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
:limit="roundCPU(limits.cpu) + ' CPU'"
/>
<StatMonitor :data="memoryData" label="mem" :stat-value="formatBytes(totalStat.memoryUsage)" />
<StatMonitor :data="cpuData" label="load" :stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'" />
</div>
</template>
@@ -25,9 +15,6 @@ const { containers } = defineProps<{
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
const { hosts } = useHosts();
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
watch(
() => containers,
@@ -55,43 +42,6 @@ watch(
{ immediate: true },
);
const limits = computed(() => {
const hostLimits = new Map<string, { cpu: number; memory: number }>();
for (const container of containers) {
if (!hostLimits.has(container.host)) {
hostLimits.set(container.host, {
cpu: 0,
memory: 0,
});
}
if (hostLimits.get(container.host)!.cpu < hosts.value[container.host].nCPU) {
if (container.cpuLimit == 0) {
hostLimits.get(container.host)!.cpu = hosts.value[container.host].nCPU;
} else {
hostLimits.get(container.host)!.cpu = hostLimits.get(container.host)!.cpu + container.cpuLimit;
}
}
if (hostLimits.get(container.host)!.memory < hosts.value[container.host].memTotal) {
if (container.memoryLimit == 0) {
hostLimits.get(container.host)!.memory = hosts.value[container.host].memTotal;
} else {
hostLimits.get(container.host)!.memory = hostLimits.get(container.host)!.memory + container.memoryLimit;
}
}
}
return hostLimits.values().reduce(
(acc, { cpu, memory }) => {
return {
cpu: acc.cpu + cpu,
memory: acc.memory + memory,
};
},
{ cpu: 0, memory: 0 },
);
});
useIntervalFn(() => {
totalStat.value = containers.reduce(
(acc, { stat }) => {
@@ -1,5 +1,5 @@
<template>
<div class="tag grid overflow-hidden rounded-sm text-center text-sm text-white">
<div class="tag grid w-40 overflow-hidden rounded-sm text-center text-sm text-white">
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
<div class="col-start-1 row-start-1 truncate px-2 brightness-100" :class="truncateRight ? '[direction:rtl]' : ''">
{{ value }}
+20 -2
View File
@@ -1,14 +1,21 @@
<template>
<LogItem :logEntry>
<div
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap"
v-html="colorize(logEntry.message)"
class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap"
v-html="linkify(colorize(logEntry.message))"
></div>
<LogMessageActions
class="absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100"
:message="() => decodeXML(stripAnsi(logEntry.message))"
:log-entry="logEntry"
/>
</LogItem>
</template>
<script lang="ts" setup>
import { SimpleLogEntry } from "@/models/LogEntry";
import { decodeXML } from "entities";
import AnsiConvertor from "ansi-to-html";
import stripAnsi from "strip-ansi";
const ansiConvertor = new AnsiConvertor({
escapeXML: false,
@@ -21,4 +28,15 @@ defineProps<{
}>();
const colorize = (value: string) => ansiConvertor.toHtml(value);
const urlPattern =
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_+.~#?&/=]*/g;
const linkify = (text: string) =>
text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
</script>
<style scoped>
@import "@/main.css" reference;
.log-wrapper :deep(a) {
@apply text-primary underline-offset-4 hover:underline;
}
</style>
@@ -2,16 +2,16 @@
<div class="my-4 flex-1 text-center">
<div class="relative">
<ZigZag class="absolute inset-0 mt-2" />
<button class="btn btn-primary btn-xs relative whitespace-pre-wrap" @click="logEntry.loadSkippedEntries()">
<span class="bg-base-200 relative px-4 py-2 font-bold whitespace-pre-wrap">
{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
</button>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { SkippedLogsEntry } from "@/models/LogEntry";
const { logEntry } = defineProps<{
defineProps<{
logEntry: SkippedLogsEntry;
}>();
</script>
+10 -16
View File
@@ -1,31 +1,25 @@
<template>
<div class="hover:text-secondary relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
<div class="border-primary overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden">
<div class="border-primary hidden overflow-hidden rounded-xs border px-px pt-1 pb-px md:flex">
<StatSparkline :data="data" @selected-point="onSelectedPoint" />
</div>
<div class="bg-base-200 inline-flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
<div class="font-light uppercase">{{ label }}</div>
<div class="font-bold select-none">
{{ mouseOver ? (selectedPoint?.value ?? selectedPoint?.y ?? statValue) : statValue }}
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
const {
data,
label,
statValue,
limit = -1,
} = defineProps<{
data: Point<unknown>[];
label: string;
statValue: string | number;
limit?: string | number;
}>();
const selectedPoint = ref<Point<unknown> | undefined>();
const onSelectedPoint = (point: Point<unknown>) => (selectedPoint.value = point);
const mouseOver = ref(false);
const { data, label, statValue } = defineProps<{ data: Point<unknown>[]; label: string; statValue: string | number }>();
let selectedPoint: Point<unknown> | undefined = $ref();
function onSelectedPoint(point: Point<unknown>) {
selectedPoint = point;
}
let mouseOver = $ref(false);
</script>
@@ -11,7 +11,7 @@ import { scaleLinear } from "d3-scale";
import { area, curveStep } from "d3-shape";
const d3 = { extent, scaleLinear, area, curveStep };
const { data, width = 175, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
const { data, width = 150, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
const x = d3.scaleLinear().range([0, width]);
const y = d3.scaleLinear().range([height, 0]);
@@ -1,117 +1,73 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
</li>
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-start dropdown-hover absolute -left-2 z-10 font-sans"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</ul>
</div>
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<!--v-if-->
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<!--v-if-->
<!--v-if-->
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<!--v-if-->
<!--v-if-->
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
<!--v-if-->
</div>
</div>
</li>
</ul>"
`;
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
</li>
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-start dropdown-hover absolute -left-2 z-10 font-sans"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</ul>
</div>
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<!--v-if-->
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<!--v-if-->
<!--v-if-->
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
</div>
<!--v-if-->
<!--v-if-->
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
<!--v-if-->
</div>
</div>
</li>
</ul>"
`;
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
</li>
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-start dropdown-hover absolute -left-2 z-10 font-sans"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</ul>
</div>
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<!--v-if-->
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<!--v-if-->
<!--v-if-->
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<!--v-if-->
<!--v-if-->
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
<!--v-if-->
</div>
</div>
</li>
</ul>"
`;
exports[`<ContainerEventSource /> > should parse messages 1`] = `
LoadMoreLogEntry {
"_message": "",
"containerID": "",
"date": 2019-06-12T10:55:42.709Z,
"id": 1560336942709,
SimpleLogEntry {
"_message": "This is a message.",
"containerID": undefined,
"date": 2019-06-12T10:55:42.459Z,
"id": 1,
"level": undefined,
"loader": [Function],
"rawMessage": "info",
"rememberScrollPosition": true,
"position": undefined,
"std": "stderr",
}
`;
@@ -2,8 +2,12 @@
<ScrollableView :scrollable="scrollable" v-if="containers.length && ready">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<octicon:container-24 />
<ContainerDropdown :containers="containers">{{ $t("label.container", containers.length) }}</ContainerDropdown>
<div class="@container flex flex-1 gap-1.5 truncate md:gap-2">
<octicon:container-24 />
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ containers.length }} containers</div>
</div>
</div>
<MultiContainerStat class="ml-auto" :containers="containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-5 px-4 py-4 md:px-8">
<div class="flex flex-col gap-8 px-4 py-4 md:px-8">
<section>
<Links>
<template #more-items>
+6 -6
View File
@@ -3,8 +3,8 @@
<teleport to="body">
<transition name="fade">
<div
v-show="show && (delayedShow || globalShow)"
class="ring-base-content/20 bg-base-100 fixed z-50 rounded-sm p-3 shadow-sm ring"
v-show="show && (delayedShow || glopbalShow)"
class="border-base-content/20 bg-base-100 fixed z-50 rounded-sm border p-4 shadow-sm"
ref="content"
>
<slot name="content"></slot>
@@ -16,14 +16,14 @@
<script lang="ts" setup>
import { globalShowPopup } from "@/composable/popup";
const globalShow = globalShowPopup();
const show = ref(globalShow.value);
const glopbalShow = globalShowPopup();
const show = ref(glopbalShow.value);
const delayedShow = refDebounced(show, 1000);
const content = ref<HTMLElement>();
const onMouseEnter = (e: Event) => {
show.value = true;
globalShow.value = true;
glopbalShow.value = true;
if (content.value && e.target instanceof HTMLElement) {
const { left, top, width } = e.target.getBoundingClientRect();
@@ -37,7 +37,7 @@ const onMouseEnter = (e: Event) => {
const onMouseLeave = () => {
show.value = false;
globalShow.value = false;
glopbalShow.value = false;
};
const el: Ref<HTMLElement> = useCurrentElement();
+2 -2
View File
@@ -12,7 +12,7 @@
<span> % </span>
</div>
</div>
<RelativeTime :date="date" class="text-sm whitespace-nowrap" />
<DistanceTime :date="date" class="text-sm whitespace-nowrap" />
</div>
</transition>
</template>
@@ -93,7 +93,7 @@ svg {
}
.fadeout-leave-active {
@apply transition-opacity duration-400;
@apply transition-opacity;
}
.fadeout-leave-to {
+23 -24
View File
@@ -2,7 +2,7 @@
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
<header
v-if="$slots.header"
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-2 border-b py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
class="border-base-content/10 bg-base-200 sticky top-[55px] z-2 border-b py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
>
<slot name="header"></slot>
</header>
@@ -25,10 +25,10 @@
<div ref="scrollObserver" class="h-px"></div>
</main>
<div class="mr-16 text-right" v-if="!historical">
<div class="mr-16 text-right">
<transition name="fade">
<button
class="btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm transition-colors"
class="transition-colorsblur-xs dark btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm"
:class="hasMore ? 'btn-secondary animate-bounce-fast text-secondary-content' : ''"
@click="scrollToBottom()"
v-show="scrollContext.paused"
@@ -49,29 +49,28 @@ const scrollableContent = ref<HTMLElement>();
const scrollContext = provideScrollContext();
const { loadingMore, historical } = useLoggingContext();
if (!historical.value) {
useIntersectionObserver(scrollObserver, ([entry]) => (scrollContext.paused = entry.intersectionRatio == 0), {
threshold: [0, 1],
rootMargin: "40px 0px",
});
const { loadingMore } = useLoggingContext();
useMutationObserver(
scrollableContent,
(records) => {
if (!scrollContext.paused) {
scrollToBottom();
} else {
const record = records[records.length - 1];
const children = (record.target as HTMLElement).children;
if (children[children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
hasMore.value = true;
}
useIntersectionObserver(scrollObserver, ([entry]) => (scrollContext.paused = entry.intersectionRatio == 0), {
threshold: [0, 1],
rootMargin: "40px 0px",
});
useMutationObserver(
scrollableContent,
(records) => {
if (!scrollContext.paused) {
scrollToBottom();
} else {
const record = records[records.length - 1];
const children = (record.target as HTMLElement).children;
if (children[children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
hasMore.value = true;
}
},
{ childList: true, subtree: true },
);
}
}
},
{ childList: true, subtree: true },
);
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
scrollObserver.value?.scrollIntoView({ behavior });
@@ -2,8 +2,15 @@
<ScrollableView :scrollable="scrollable" v-if="service.name">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<ph:stack-simple />
<ContainerDropdown :containers="service.containers">{{ service.name }}</ContainerDropdown>
<div class="@container flex flex-1 gap-1.5 truncate md:gap-2">
<ph:stack-simple />
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ service.name }}</div>
</div>
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
{{ $t("label.container", service.containers.length) }}
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="service.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
+8 -7
View File
@@ -1,16 +1,17 @@
<template>
<aside class="fixed flex h-screen w-[inherit] flex-col gap-4 p-3" data-testid="navigation">
<h1>
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-4 overflow-hidden text-4xl font-thin">
<Logo class="h-14 w-14 shrink-0" />
Dozzle
<router-link :to="{ name: '/' }">
<LogoWithText class="logo [&_.secondary-fill]:fill-secondary [&_.content-fill]:fill-base-content h-16 w-40" />
</router-link>
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
<small class="mb-4 block text-xs font-light" v-if="hostname">
{{ hostname }}
</small>
</h1>
<button
class="input input-sm hover:border-primary mt-2 inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
class="input input-sm hover:border-primary inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
@click="$emit('search')"
:title="$t('tooltip.search')"
data-testid="search"
@@ -20,12 +21,12 @@
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
</button>
<SideMenu class="flex-1" />
<SideMenu class="mt-2 flex-1" />
</aside>
</template>
<script lang="ts" setup>
import Logo from "@/logo.svg";
import LogoWithText from "@/logo-text.svg";
const { hostname } = config;
</script>
+9 -4
View File
@@ -2,12 +2,17 @@
<ScrollableView :scrollable="scrollable" v-if="stack.name">
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
<div class="@container flex flex-1 gap-1.5 truncate md:gap-2">
<ph:stack />
<div class="font-mono text-sm font-semibold">{{ stack.name }}</div>
<ContainerDropdown :containers="stack.containers">
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ stack.name }}</div>
</div>
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
{{ $t("label.container", stack.containers.length) }}
</ContainerDropdown>
</Tag>
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
{{ $t("label.service", stack.services.length) }}
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
+1 -31
View File
@@ -1,28 +1,5 @@
<template>
<div class="mb-2 flex items-center">
<div class="flex-1">
{{ $t("label.service", services.length) }}
</div>
<div class="flex-none">
<div class="dropdown dropdown-end dropdown-hover">
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
<ph:dots-three-vertical-bold />
</label>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
>
<li>
<a class="text-sm capitalize" @click="collapseAll()">
<material-symbols-light:collapse-all class="w-4" />
{{ $t("label.collapse-all") }}
</a>
</li>
</ul>
</div>
</div>
</div>
<ul class="menu w-full p-0 text-[0.95rem]" ref="menu">
<ul class="menu w-full p-0 text-[0.95rem]">
<li v-for="{ name, services } in stacks" :key="name">
<details open>
<summary class="text-base-content/80 font-light">
@@ -78,11 +55,4 @@ const store = useSwarmStore();
const { stacks, services } = storeToRefs(store);
const servicesWithoutStacks = computed(() => services.value.filter((service) => !service.stack));
const menu = useTemplateRef("menu");
const collapseAll = () => {
const details = menu.value?.querySelectorAll("details");
details?.forEach((detail) => (detail.open = false));
};
</script>
+3 -19
View File
@@ -3,7 +3,7 @@
<header class="flex items-center gap-4">
<material-symbols:terminal class="size-8" />
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
<h2 class="text-sm">Started <RelativeTime :date="container.created" /></h2>
<h2 class="text-sm">Started <DistanceTime :date="container.created" /></h2>
</header>
<div class="mt-8 flex flex-col gap-2">
@@ -36,7 +36,7 @@ onMounted(() => {
terminal.resize(100, 40);
ws = new WebSocket(withBase(`/api/hosts/${container.host}/containers/${container.id}/${action}`));
ws.onopen = () => {
terminal.writeln(`Attaching to ${container.name} 🚀`);
terminal.writeln(`Attached to ${container.name} 🚀`);
if (action === "attach") {
ws?.send("\r");
}
@@ -58,7 +58,7 @@ onUnmounted(() => {
});
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
.shell {
& :deep(.terminal) {
@@ -75,21 +75,5 @@ onUnmounted(() => {
& :deep(.xterm-rows) {
@apply text-base-content;
}
& :deep(.xterm-cursor-block.xterm-cursor-blink) {
animation-name: blink !important;
}
}
@keyframes blink {
0% {
background-color: var(--color-base-content);
color: #000000;
}
50% {
background-color: inherit;
color: var(--color-base-content);
}
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<time :datetime="date.toISOString()" data-ci-skip>{{ text }}</time>
</template>
<script lang="ts" setup>
import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
const {
date,
strict = false,
suffix = true,
} = defineProps<{
date: Date;
strict?: boolean;
suffix?: boolean;
}>();
const text = ref<string>();
watch($$(date), updateFromNow, { immediate: true });
function updateFromNow() {
const fn = strict ? formatDistanceToNowStrict : formatDistanceToNow;
text.value = fn(date, {
addSuffix: suffix,
});
}
useIntervalFn(updateFromNow, 30_000, { immediateCallback: true });
</script>
+2 -2
View File
@@ -1,10 +1,10 @@
<template>
<details class="dropdown dropdown-end" ref="details" v-on-click-outside="close">
<details class="dropdown" ref="details" v-on-click-outside="close">
<summary class="btn btn-primary flex-nowrap" v-bind="$attrs">
<slot name="trigger"> {{ label }} <carbon:caret-down /></slot>
</summary>
<ul
class="menu dropdown-content rounded-box border-base-content/20 bg-base-200 z-50 mt-1 max-h-72 w-48 flex-nowrap overflow-auto border p-2 shadow-sm"
class="menu dropdown-content rounded-box border-base-content/20 bg-base-200 z-50 mt-1 w-52 border p-2 shadow-sm"
>
<slot>
<li v-for="item in options">
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<label class="label text-base-content cursor-pointer gap-4">
<div class="flex-1 whitespace-normal"><slot name="label" /></div>
<div class="flex-1"><slot name="label" /></div>
<slot name="input" />
</label>
</template>
+21 -23
View File
@@ -1,29 +1,27 @@
<template>
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
<div class="p-2">
<div class="flex items-center">
<router-link :to="{ name: '/' }">
<Logo class="h-10" />
</router-link>
<nav class="border-base-content/20 bg-base-200 fixed top-0 z-10 w-full border-b p-2" data-testid="navigation">
<div class="flex items-center">
<router-link :to="{ name: '/' }">
<Logo class="logo [&_.secondary-fill]:fill-secondary h-8" />
</router-link>
<div class="ml-auto flex items-center gap-2">
<a class="btn btn-circle flex" @click="$emit('search')" :title="$t('tooltip.search')">
<mdi:magnify class="size-5" />
</a>
<label class="btn btn-circle swap swap-rotate" data-testid="hamburger">
<input type="checkbox" v-model="show" />
<mdi:close class="swap-on" />
<mdi:hamburger-menu class="swap-off" />
</label>
</div>
<div class="ml-auto flex items-center gap-2">
<a class="btn btn-circle flex" @click="$emit('search')" :title="$t('tooltip.search')">
<mdi:magnify class="size-5" />
</a>
<label class="btn btn-circle swap swap-rotate" data-testid="hamburger">
<input type="checkbox" v-model="show" />
<mdi:close class="swap-on" />
<mdi:hamburger-menu class="swap-off" />
</label>
</div>
<transition name="fade">
<div v-show="show" class="flex h-[calc(100svh-55px)]">
<SideMenu class="flex-1" />
</div>
</transition>
</div>
<transition name="fade">
<div v-show="show" class="flex h-[calc(100svh-55px)]">
<SideMenu class="flex-1" />
</div>
</transition>
</nav>
</template>
@@ -37,7 +35,7 @@ watch(route, () => {
});
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
.fade-enter-active,
.fade-leave-active {
@apply transition-opacity;
-17
View File
@@ -1,17 +0,0 @@
<template>
<time :datetime="date.toISOString()">{{ text }}</time>
</template>
<script lang="ts" setup>
const { date } = defineProps<{
date: Date;
}>();
const text = ref<string>();
const updateFromNow = () => {
text.value = toRelativeTime(date, locale.value === "" ? undefined : locale.value);
};
watch(() => date, updateFromNow, { immediate: true });
useIntervalFn(updateFromNow, 30_000);
</script>
+8 -13
View File
@@ -1,18 +1,13 @@
<template>
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
<div class="modal-box" :width="width">
<div class="pt-safe relative">
<form method="dialog" class="absolute right-0">
<button v-if="isMobile">
<mdi:close />
</button>
<button v-else class="swap hover:swap-active outline-hidden">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
<slot v-if="open"></slot>
</div>
<form method="dialog">
<button class="swap hover:swap-active absolute top-4 right-4 outline-hidden">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
<slot v-if="open"></slot>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
@@ -38,7 +33,7 @@ defineExpose({
useEventListener(panel, "close", () => (open.value = false));
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
.modal-right :where(.modal-box) {
@apply bg-base-100 fixed right-0 h-lvh max-h-screen translate-x-24 scale-100 rounded-none shadow-none;
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="toast toast-end whitespace-normal max-md:end-auto max-md:m-0 max-md:max-w-full">
<div class="toast toast-end whitespace-normal max-md:m-0 max-md:w-full">
<div
class="alert max-w-xl shadow-sm max-md:rounded-none"
class="alert max-w-xl max-md:rounded-none"
v-for="{ toast, options: { timed } } in toasts"
:key="toast.id"
:class="{
+42 -87
View File
@@ -9,7 +9,6 @@ import {
ContainerEventLogEntry,
ComplexLogEntry,
SkippedLogsEntry,
LoadMoreLogEntry,
} from "@/models/LogEntry";
import { Service, Stack } from "@/models/Stack";
import { Container, GroupedContainers } from "@/models/Container";
@@ -23,7 +22,8 @@ function parseMessage(data: string): LogEntry<string | JSONObject> {
export function useContainerStream(container: Ref<Container>): LogStreamSource {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`);
return useLogStream(url, container);
const loadMoreUrl = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
return useLogStream(url, loadMoreUrl);
}
export function useHostStream(host: Ref<Host>): LogStreamSource {
@@ -53,15 +53,13 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
export type LogStreamSource = ReturnType<typeof useLogStream>;
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
const buffer: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
const { paused: scrollingPaused } = useScrollContext();
const { streamConfig, hasComplexLogs, levels, loadingMore } = useLoggingContext();
let initial = true;
function flushNow() {
if (messages.value.length + buffer.value.length > config.maxLogs) {
@@ -73,9 +71,10 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
} else {
const firstItem = buffer.value.at(0) as LogEntry<string | JSONObject>;
const lastItem = buffer.value.at(-1) as LogEntry<string | JSONObject>;
messages.value = [
...messages.value,
new SkippedLogsEntry(new Date(), buffer.value.length, firstItem, lastItem, loadSkippedLogs),
new SkippedLogsEntry(new Date(), buffer.value.length, firstItem, lastItem),
];
}
buffer.value = [];
@@ -88,15 +87,9 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
buffer.value = [];
}
} else {
if (initial) {
if (messages.value.length == 0) {
// sort the buffer the very first time because of multiple logs in parallel
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
if (container) {
const loadMoreItem = new LoadMoreLogEntry(new Date(), loadOlderLogs);
messages.value = [loadMoreItem];
}
initial = false;
}
messages.value = [...messages.value, ...buffer.value];
buffer.value = [];
@@ -118,6 +111,8 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
buffer.value = [];
}
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
const params = computed(() => {
const params = new URLSearchParams();
if (streamConfig.value.stdout) params.append("stdout", "1");
@@ -137,7 +132,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
opened.value = false;
loading.value = true;
error.value = false;
initial = true;
es = new EventSource(urlWithParams.value);
es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as {
@@ -181,48 +175,46 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
watch(urlWithParams, () => connect(), { immediate: true });
async function loadOlderLogs(entry: LoadMoreLogEntry) {
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
if (!container) throw new Error("No container");
const isLoadingMore = ref(false);
const [loader, ...existingLogs] = messages.value;
const to = existingLogs[0].date;
const lastSeenId = existingLogs[0].id;
async function loadOlderLogs() {
if (!loadMoreUrl) return;
if (isLoadingMore.value) return;
const to = messages.value[0].date;
const lastSeenId = messages.value[0].id;
const last = messages.value[Math.min(messages.value.length - 1, 300)].date;
const delta = to.getTime() - last.getTime();
const from = new Date(to.getTime() + delta);
const abortController = new AbortController();
const signal = abortController.signal;
isLoadingMore.value = true;
try {
loadingMore.value = true;
const { logs: newLogs, signal } = await loadBetween(container, params, from, to, {
min: 100,
lastSeenId,
const urlWithMoreParams = computed(() => {
const loadMoreParams = new URLSearchParams(params.value);
loadMoreParams.append("from", from.toISOString());
loadMoreParams.append("to", to.toISOString());
loadMoreParams.append("minimum", "100");
loadMoreParams.append("lastSeenId", String(lastSeenId));
return withBase(`${loadMoreUrl.value}?${loadMoreParams.toString()}`);
});
if (newLogs && signal.aborted === false) {
messages.value = [loader, ...newLogs, ...existingLogs];
}
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
stopWatcher();
async function loadSkippedLogs(entry: SkippedLogsEntry) {
if (!container) throw new Error("No container");
const from = entry.firstSkipped.date;
const to = entry.lastSkippedLog.date;
const lastSeenId = entry.lastSkippedLog.id;
try {
loadingMore.value = true;
const { logs, signal } = await loadBetween(container, params, from, to, { lastSeenId });
if (logs && signal.aborted === false) {
messages.value = messages.value.slice(logs.length).flatMap((log) => (log === entry ? logs : [log]));
const newMessages = logs
.trim()
.split("\n")
.map((line) => parseMessage(line));
messages.value = [...newMessages, ...messages.value];
}
} catch (error) {
console.error(error);
} catch (e) {
console.error("Error loading older logs", e);
} finally {
loadingMore.value = false;
isLoadingMore.value = false;
}
}
@@ -236,49 +228,12 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
return {
messages,
loadOlderLogs,
isLoadingMore,
hasComplexLogs,
opened,
error,
loading,
};
}
export async function loadBetween(
container: Ref<Container>,
params: Ref<URLSearchParams>,
from: Date,
to: Date,
{ lastSeenId, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
) {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
const abortController = new AbortController();
const signal = abortController.signal;
const urlWithMoreParams = computed(() => {
const loadMoreParams = new URLSearchParams(params.value);
loadMoreParams.append("from", from.toISOString());
loadMoreParams.append("to", to.toISOString());
if (min) {
loadMoreParams.append("min", String(min));
}
if (maxStart) {
loadMoreParams.append("maxStart", String(maxStart));
}
if (lastSeenId) {
loadMoreParams.append("lastSeenId", String(lastSeenId));
}
return withBase(`${url.value}?${loadMoreParams.toString()}`);
});
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
stopWatcher();
if (!logs) return { logs: [], signal };
return {
logs: logs
.trim()
.split("\n")
.map((line) => parseMessage(line)),
signal,
eventSourceURL: urlWithParams,
};
}
-125
View File
@@ -1,125 +0,0 @@
import { HistoricalContainer } from "@/models/Container";
import { JSONObject, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
import { ShallowRef } from "vue";
import { loadBetween } from "@/composable/eventStreams";
export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalContainer>): LogStreamSource {
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
const container = toRef(() => historicalContainer.value.container);
const { streamConfig, levels, loadingMore } = useLoggingContext();
const { isSearching, debouncedSearchFilter } = useSearchFilter();
const params = computed(() => {
const params = new URLSearchParams();
if (streamConfig.value.stdout) params.append("stdout", "1");
if (streamConfig.value.stderr) params.append("stderr", "1");
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
for (const level of levels.value) {
params.append("levels", level);
}
return params;
});
const route = useRoute();
async function loadLogs() {
loadingMore.value = true;
try {
const lastSeenId = route.query.logId ? +route.query.logId : undefined;
const [{ logs: before }, { logs: after }] = await Promise.all([
loadBetween(
container,
params,
new Date(historicalContainer.value.date.getTime() - 1000 * 60 * 5),
new Date(historicalContainer.value.date.getTime() + 1000),
{
min: 50,
lastSeenId,
},
),
loadBetween(container, params, historicalContainer.value.date, new Date(), {
maxStart: 50,
}),
]);
const loaderOlder = new LoadMoreLogEntry(new Date(), loadOlderLogs);
const loadNewer = new LoadMoreLogEntry(new Date(), loadNewerLogs, false);
messages.value = [loaderOlder, ...before, ...after, loadNewer];
loading.value = false;
opened.value = true;
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
watchArray([params, container], loadLogs, { immediate: true });
async function loadOlderLogs(entry: LoadMoreLogEntry) {
loadingMore.value = true;
try {
const item = messages.value[1];
const { logs, signal } = await loadBetween(
container,
params,
new Date(item.date.getTime() - 1000 * 60 * 5),
item.date,
{
min: 200,
lastSeenId: item.id,
},
);
if (signal.aborted) {
return;
}
if (!logs.length) {
return;
}
const [loader, ...rest] = messages.value;
messages.value = [loader, ...logs, ...rest];
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
async function loadNewerLogs(entry: LoadMoreLogEntry) {
loadingMore.value = true;
try {
const item = messages.value.at(-2)!;
const { logs, signal } = await loadBetween(container, params, item.date, new Date(), {
maxStart: 100,
});
if (signal.aborted) {
return;
}
if (!logs.length) {
return;
}
const loader = messages.value.at(-1)!;
const rest = messages.value.slice(0, -1);
messages.value = [...rest, ...logs, loader];
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
return {
messages,
opened,
error,
loading,
};
}
+1 -4
View File
@@ -9,7 +9,6 @@ type LogContext = {
levels: Set<Level>;
showContainerName: boolean;
showHostname: boolean;
historical: boolean;
};
export const allLevels: Level[] = ["info", "debug", "warn", "error", "fatal", "trace", "unknown"];
@@ -22,7 +21,7 @@ const stderr = searchParams.has("stderr") ? searchParams.get("stderr") === "true
export const provideLoggingContext = (
containers: Ref<Container[]>,
{ showContainerName = false, showHostname = false, historical = false } = {},
{ showContainerName = false, showHostname = false } = {},
) => {
provide(
loggingContextKey,
@@ -34,7 +33,6 @@ export const provideLoggingContext = (
levels: new Set<Level>(allLevels),
showContainerName,
showHostname,
historical,
}),
);
};
@@ -50,7 +48,6 @@ export const useLoggingContext = () => {
levels: new Set<Level>(allLevels),
showContainerName: false,
showHostname: false,
historical: false,
}),
);
+6 -3
View File
@@ -1,4 +1,5 @@
type ScrollContext = {
loading: boolean;
paused: boolean;
progress: number;
currentDate: Date;
@@ -8,18 +9,20 @@ type ScrollContext = {
export const scrollContextKey = Symbol("scrollContext") as InjectionKey<ScrollContext>;
export const provideScrollContext = () => {
const context = defaultValue();
const context = defauleValue();
provide(scrollContextKey, context);
return context;
};
export const useScrollContext = () => {
const context = inject(scrollContextKey, defaultValue());
const defaultValue = defauleValue();
const context = inject(scrollContextKey, defaultValue);
return toRefs(context);
};
function defaultValue() {
function defauleValue() {
return reactive({
loading: false,
paused: false,
progress: 1,
currentDate: new Date(),
+8 -4
View File
@@ -1,6 +1,6 @@
<template>
<div>
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
<mobile-menu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></mobile-menu>
<Splitpanes @resized="onResized($event)">
<Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
<SidePanel @search="showFuzzySearch" />
@@ -25,7 +25,7 @@
</Pane>
</Splitpanes>
<label
class="btn btn-circle swap bg-base-content/10 swap-rotate border-base-content/20 hover:border-primary fixed bottom-4 -left-12 w-16 shadow-sm transition-all hover:-left-4"
class="btn btn-circle swap bg-base-100 swap-rotate fixed bottom-4 -left-12 w-16 transition-all hover:-left-4"
:class="{ '-left-6!': collapseNav }"
v-if="!isMobile && !forceMenuHidden"
>
@@ -34,7 +34,11 @@
<mdi:chevron-left class="swap-off" />
</label>
</div>
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="open = false">
<dialog
ref="modal"
class="modal items-start bg-white/20 transition-none backdrop:backdrop-blur-xs"
@close="open = false"
>
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
<FuzzySearchModal @close="open = false" v-if="open" />
</div>
@@ -95,7 +99,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
:deep(.splitpanes--vertical > .splitpanes__splitter) {
@apply bg-base-100 hover:bg-secondary min-w-[5px];
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="hero max-h-500:pt-4 min-h-dvh content-start pt-[20dvh]">
<div class="hero min-h-dvh">
<div class="hero-content">
<router-view></router-view>
</div>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 396 114"><path class="secondary-fill" d="M89.408 46.477a6.49 6.49 0 0 0-6.488 6.49 6.488 6.488 0 1 0 12.978 0 6.49 6.49 0 0 0-6.49-6.49m-42.836 0a6.49 6.49 0 1 0 0 12.98 6.49 6.49 0 0 0 0-12.98M28.5 38.295a6.5 6.5 0 0 1-1.897-4.593 6.474 6.474 0 0 1 6.49-6.49c1.663 0 3.327.631 4.592 1.896l-1.517 1.12a51.67 51.67 0 0 1 31.822-10.91c11.999 0 23.028 4.078 31.823 10.91L98.3 29.113a6.47 6.47 0 0 1 4.592-1.897c1.659 0 3.324.63 4.588 1.897a6.52 6.52 0 0 1 1.892 4.593 6.47 6.47 0 0 1-1.892 4.594l1.514 1.113c21.944 21.942-15.406 31.822-15.406 31.822H42.392c-1.077 0-38.88-8.35-15.406-31.822zM102.214 94.411a7.894 7.894 0 0 1-7.894-7.895 7.893 7.893 0 1 1 15.787 0 7.89 7.89 0 0 1-7.893 7.895M37.355 95.13a7.894 7.894 0 0 1-7.895-7.896 7.894 7.894 0 0 1 7.895-7.893 7.894 7.894 0 0 1 7.896 7.893 7.894 7.894 0 0 1-7.896 7.895M44.372 72.866h47.402s-22.981 7.899-47.402 0"/><path class="content-fill" d="M142 79.928V33.446h13.836c3.571 0 6.886.574 9.947 1.786 3.06 1.147 5.675 2.805 7.779 4.846 4.272 4.144 6.822 10.01 6.822 16.514 0 3.379-.637 6.567-1.913 9.5-1.275 2.933-3.06 5.356-5.228 7.396-4.4 4.017-10.648 6.44-17.407 6.44zm4.91-41.89v37.3h8.735c3.889 0 7.332-.83 10.265-2.423 2.997-1.595 5.356-3.762 6.95-6.568 1.658-2.805 2.487-6.057 2.487-9.628 0-5.292-1.977-10.01-5.356-13.39-3.315-3.315-8.48-5.292-14.346-5.292zm86.587 18.554c0 4.4-1.02 8.416-2.997 12.05q-3.06 5.452-8.416 8.608c-3.634 2.104-7.588 3.188-12.051 3.188-3.252 0-6.312-.637-9.245-1.849-2.869-1.211-5.356-2.869-7.397-5.037-2.04-2.04-3.634-4.527-4.845-7.46-1.148-2.933-1.786-5.993-1.786-9.245q0-6.6 3.061-12.051c1.976-3.635 4.782-6.568 8.416-8.672C201.808 34.02 205.825 33 210.224 33c3.252 0 6.313.574 9.246 1.785 2.869 1.276 5.356 2.933 7.396 4.974 2.04 2.168 3.698 4.654 4.846 7.523 1.211 2.934 1.785 5.994 1.785 9.31m-5.037.255c0-5.229-1.913-10.075-5.101-13.581s-7.97-5.739-13.326-5.739c-3.443 0-6.567.893-9.373 2.55-2.805 1.786-4.973 4.081-6.503 6.95q-2.296 4.4-2.296 9.565c0 5.164 1.913 10.074 5.101 13.517 3.188 3.507 7.97 5.739 13.262 5.739 3.444 0 6.568-.893 9.373-2.615 2.87-1.657 5.037-4.017 6.568-6.886 1.53-2.87 2.295-6.057 2.295-9.5m43.932 18.618v4.463h-33.539v-4.463l27.672-37.555H239.81v-4.464h32.454v4.464l-27.8 37.555zm38.192 0v4.463h-33.538v-4.463l27.672-37.555h-26.715v-4.464h32.454v4.464l-27.8 37.555zm8.927 4.463V33.446h4.91v41.891h22.061v4.591zm34.622 0V33.446h29.075v4.527h-24.165v16.068h22.125v4.527h-22.125v16.833h24.42v4.527z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 934 B

+5 -15
View File
@@ -4,23 +4,13 @@
@plugin "@tailwindcss/typography";
@theme {
--color-green: oklch(0.62 0.119722 158.82);
--color-green: oklch(69% 0.119722 188.479048);
--color-red: oklch(64% 0.218 28.85);
--color-purple: oklch(51.49% 0.215 321.03);
--color-blue: oklch(65% 0.171 249.5);
--color-orange: oklch(70% 0.186 48.13);
}
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}
@custom-variant max-h-500 {
@media (max-height: 500px) {
@slot;
}
}
@plugin "daisyui/theme" {
name: "dark";
default: false;
@@ -137,13 +127,13 @@
}
}
nav {
padding-top: env(safe-area-inset-top);
}
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
[class*="shadow-"] {
@apply shadow-base-content/8;
}
-9
View File
@@ -21,13 +21,6 @@ export class GroupedContainers {
) {}
}
export class HistoricalContainer {
constructor(
public readonly container: Container,
public readonly date: Date,
) {}
}
export class Container {
private _stat: Ref<Stat>;
private _name: string;
@@ -45,8 +38,6 @@ export class Container {
public readonly host: string,
public readonly labels = {} as Record<string, string>,
public state: ContainerState,
public readonly cpuLimit: number,
public readonly memoryLimit: number,
stats: Stat[],
public readonly group?: string,
public health?: ContainerHealth,
+9 -40
View File
@@ -4,7 +4,6 @@ import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
import ContainerEventLogItem from "@/components/LogViewer/ContainerEventLogItem.vue";
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
import LoadMoreLogItem from "@/components/LogViewer/LoadMoreLogItem.vue";
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
export type JSONObject = { [x: string]: JSONValue };
@@ -29,7 +28,6 @@ export interface LogEvent {
readonly p: Position;
readonly s: "stdout" | "stderr" | "unknown";
readonly c: string;
readonly rm: string;
}
export abstract class LogEntry<T extends string | JSONObject> {
@@ -40,7 +38,6 @@ export abstract class LogEntry<T extends string | JSONObject> {
public readonly id: number,
public readonly date: Date,
public readonly std: Std,
public readonly rawMessage: string,
public readonly level?: Level,
) {
this._message = message;
@@ -62,9 +59,8 @@ export class SimpleLogEntry extends LogEntry<string> {
public readonly level: Level,
public readonly position: Position,
public readonly std: Std,
public readonly rawMessage: string,
) {
super(message, containerID, id, date, std, rawMessage, level);
super(message, containerID, id, date, std, level);
}
getComponent(): Component {
return SimpleLogItem;
@@ -81,10 +77,9 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
date: Date,
public readonly level: Level,
public readonly std: Std,
public readonly rawMessage: string,
visibleKeys?: Ref<Map<string[], boolean>>,
) {
super(message, containerID, id, date, std, rawMessage, level);
super(message, containerID, id, date, std, level);
if (visibleKeys) {
this.filteredMessage = computed(() => {
if (visibleKeys.value.size === 0) {
@@ -128,7 +123,6 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
event.date,
event.level,
event.std,
event.rawMessage,
visibleKeys,
);
}
@@ -149,7 +143,7 @@ export class ContainerEventLogEntry extends LogEntry<string> {
}
export class SkippedLogsEntry extends LogEntry<string> {
private _totalSkipped = ref(0);
private _totalSkipped = 0;
private lastSkipped: LogEntry<string | JSONObject>;
constructor(
@@ -157,10 +151,9 @@ export class SkippedLogsEntry extends LogEntry<string> {
totalSkipped: number,
public readonly firstSkipped: LogEntry<string | JSONObject>,
lastSkipped: LogEntry<string | JSONObject>,
private readonly loader: (i: SkippedLogsEntry) => Promise<void>,
) {
super("", "", date.getTime(), date, "stderr", "info");
this._totalSkipped.value = totalSkipped;
this._totalSkipped = totalSkipped;
this.lastSkipped = lastSkipped;
}
getComponent(): Component {
@@ -168,42 +161,20 @@ export class SkippedLogsEntry extends LogEntry<string> {
}
public get message(): string {
return `Skipped ${this._totalSkipped.value} entries`;
return `Skipped ${this.totalSkipped} entries`;
}
public addSkippedEntries(totalSkipped: number, lastItem: LogEntry<string | JSONObject>) {
this._totalSkipped.value += totalSkipped;
this._totalSkipped += totalSkipped;
this.lastSkipped = lastItem;
}
public get lastSkippedLog(): LogEntry<string | JSONObject> {
return this.lastSkipped;
}
public async loadSkippedEntries(): Promise<void> {
await this.loader(this);
}
public get totalSkipped(): number {
return unref(this._totalSkipped);
}
}
export class LoadMoreLogEntry extends LogEntry<string> {
constructor(
date: Date,
private readonly loader: (i: LoadMoreLogEntry) => Promise<void>,
public readonly rememberScrollPosition: boolean = true,
) {
super("", "", date.getTime(), date, "stderr", "info");
return this._totalSkipped;
}
getComponent(): Component {
return LoadMoreLogItem;
}
async loadMore(): Promise<void> {
await this.loader(this);
public get lastSkippedItem(): LogEntry<string | JSONObject> {
return this.lastSkipped;
}
}
@@ -216,7 +187,6 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
new Date(event.ts),
event.l,
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
event.rm,
);
} else {
return new SimpleLogEntry(
@@ -227,7 +197,6 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
event.l,
event.p,
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
event.rm,
);
}
}
@@ -1,35 +0,0 @@
<template>
<HistoricalContainerLog :id :date show-title :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<div v-else-if="ready" class="hero bg-base-200 min-h-screen">
<div class="hero-content text-center">
<div class="max-w-md">
<p class="py-6 text-2xl font-bold">{{ $t("error.container-not-found") }}</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
const route = useRoute("/container/[id].time.[datetime]");
const id = toRef(() => route.params.id);
const date = toRef(() => new Date(route.params.datetime));
const containerStore = useContainerStore();
const currentContainer = containerStore.currentContainer(id);
const { ready } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
watchEffect(() => {
if (ready.value) {
if (currentContainer.value) {
setTitle(currentContainer.value.name);
} else {
setTitle("Not Found");
}
}
});
</script>
<route lang="yaml">
meta:
menu: host
</route>
+2 -13
View File
@@ -1,6 +1,6 @@
<template>
<Search />
<ContainerLog :id show-title :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<ContainerLog :id="id" :show-title="true" :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<div v-else-if="ready" class="hero bg-base-200 min-h-screen">
<div class="hero-content text-center">
<div class="max-w-md">
@@ -39,7 +39,6 @@ watch(currentContainer, () => (redirectTrigger.value = false));
watchEffect(() => {
if (redirectTrigger.value) return;
if (automaticRedirect.value === "none") return;
if (!currentContainer.value) return;
if (currentContainer.value.state === "running") return;
if (Date.now() - +currentContainer.value.finishedAt > 5 * 60 * 1000) return;
@@ -50,7 +49,7 @@ watchEffect(() => {
if (!nextContainer) return;
if (automaticRedirect.value === "delayed") {
if (automaticRedirect.value) {
redirectTrigger.value = true;
showToast(
{
@@ -74,16 +73,6 @@ watchEffect(() => {
},
{ timed: 4000 },
);
} else {
router.push({ name: "/container/[id]", params: { id: nextContainer.id } });
showToast(
{
title: t("alert.redirected.title"),
message: t("alert.redirected.message", { containerId: nextContainer.id }),
type: "info",
},
{ expire: 3000 },
);
}
});
</script>
+3 -3
View File
@@ -4,7 +4,7 @@
<form action="" method="post" @submit.prevent="onLogin" ref="form" class="flex flex-col gap-8">
<label class="form-control w-full">
<label
class="input input-lg floating-label input-bordered has-[:focus]:input-primary flex w-full items-center gap-2 border-2"
class="input floating-label input-bordered has-[:focus]:input-primary flex items-center gap-2 border-2"
:class="{ 'input-error': error }"
>
<span class="ml-5">{{ $t("label.username") }}</span>
@@ -28,7 +28,7 @@
</label>
<label class="form-control w-full">
<label
class="input input-lg floating-label input-bordered has-[:focus]:input-primary flex w-full items-center gap-2 border-2"
class="input floating-label input-bordered has-[:focus]:input-primary flex items-center gap-2 border-2"
>
<span class="ml-5">{{ $t("label.password") }}</span>
<mdi:key class="has-[+:focus]:text-primary" />
@@ -44,7 +44,7 @@
</label>
</label>
<button class="btn btn-primary mt-2 uppercase" type="submit" :disabled="loading">
<button class="btn btn-primary uppercase" type="submit" :disabled="loading">
<span class="loading loading-spinner" v-if="loading"></span>
{{ $t("button.login") }}
</button>
+13 -54
View File
@@ -5,35 +5,12 @@
<h2>{{ $t("settings.about") }}</h2>
</div>
<div class="flex flex-row gap-2">
<div>
<span v-html="$t('settings.using-version', { version: config.version })"></span>
<span
<div
v-if="hasRelease"
v-html="$t('settings.update-available', { nextVersion: latestRelease?.name, href: latestRelease?.htmlUrl })"
></span>
</div>
<div class="mt-4">
{{ $t("settings.help-support") }}
<ul class="mt-6 flex gap-2">
<li>
<a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer" class="btn">
<mdi:github /> amir20/dozzle
</a>
</li>
<li>
<a
href="https://buymeacoffee.com/amirraminfar"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary"
>
<mdi:beer />
Buy me a beer
</a>
</li>
</ul>
></div>
</div>
</section>
@@ -142,28 +119,13 @@
<div class="has-underline">
<h2>{{ $t("settings.options") }}</h2>
</div>
<LabeledInput>
<template #label>
{{ $t("settings.automatic-redirect") }}
</template>
<template #input>
<DropdownMenu
v-model="automaticRedirect"
:options="[
{ label: 'Instant', value: 'instant' },
{ label: 'Delayed', value: 'delayed' },
{ label: 'None', value: 'none' },
]"
/>
</template>
</LabeledInput>
<Toggle v-model="search">
{{ $t("settings.search") }} <key-shortcut char="f" class="align-top"></key-shortcut>
</Toggle>
<Toggle v-model="showAllContainers">{{ $t("settings.show-stopped-containers") }}</Toggle>
<Toggle v-model="automaticRedirect">{{ $t("settings.automatic-redirect") }}</Toggle>
</section>
</PageWithLinks>
</template>
@@ -204,8 +166,8 @@ const hoursAgo = (hours: number) => {
const fakeMessages = computedWithControl(
() => i18n.global.locale.value,
() => [
new SimpleLogEntry(t("settings.log.preview"), "123", 1, hoursAgo(16), "info", undefined, "stdout", ""),
new SimpleLogEntry(t("settings.log.warning"), "123", 2, hoursAgo(12), "warn", undefined, "stdout", ""),
new SimpleLogEntry(t("settings.log.preview"), "123", 1, hoursAgo(16), "info", undefined, "stdout"),
new SimpleLogEntry(t("settings.log.warning"), "123", 2, hoursAgo(12), "warn", undefined, "stdout"),
new SimpleLogEntry(
t("settings.log.multi-line-error.start-line"),
"123",
@@ -214,7 +176,6 @@ const fakeMessages = computedWithControl(
"error",
"start",
"stderr",
"",
),
new SimpleLogEntry(
t("settings.log.multi-line-error.middle-line"),
@@ -224,9 +185,8 @@ const fakeMessages = computedWithControl(
"error",
"middle",
"stderr",
"",
),
new SimpleLogEntry(t("settings.log.multi-line-error.end-line"), "123", 5, new Date(), "error", "end", "stderr", ""),
new SimpleLogEntry(t("settings.log.multi-line-error.end-line"), "123", 5, new Date(), "error", "end", "stderr"),
new ComplexLogEntry(
{
message: t("settings.log.complex"),
@@ -240,24 +200,23 @@ const fakeMessages = computedWithControl(
new Date(),
"info",
"stdout",
"",
),
new SimpleLogEntry(t("settings.log.simple"), "123", 7, new Date(), "debug", undefined, "stderr", ""),
new SimpleLogEntry(t("settings.log.simple"), "123", 7, new Date(), "debug", undefined, "stderr"),
],
);
</script>
<style scoped>
@reference "@/main.css";
@import "@/main.css" reference;
.has-underline {
@apply border-base-content/50 mb-4 border-b py-2;
@apply border-base-content/50 mb-4 border-b py-4;
h2 {
@apply text-3xl;
@apply text-2xl;
}
}
:deep(a:not(.menu a):not(.btn)) {
:deep(a:not(.menu a)) {
@apply text-primary underline-offset-4 hover:underline;
}
</style>
-1
View File
@@ -11,7 +11,6 @@ export interface Config {
hosts: Host[];
authProvider: "simple" | "none" | "forward-proxy";
enableActions: boolean;
enableShell: boolean;
user?: {
username: string;
email: string;
-2
View File
@@ -154,8 +154,6 @@ export const useContainerStore = defineStore("container", () => {
c.host,
c.labels,
c.state,
c.cpuLimit,
c.memoryLimit,
c.stats,
c.group,
c.health,
+2 -10
View File
@@ -14,7 +14,7 @@ export type Settings = {
dateLocale: "auto" | "en-US" | "en-GB" | "de-DE" | "en-CA";
softWrap: boolean;
collapseNav: boolean;
automaticRedirect: "instant" | "delayed" | "none";
automaticRedirect: boolean;
locale: string;
};
export const DEFAULT_SETTINGS: Settings = {
@@ -31,20 +31,12 @@ export const DEFAULT_SETTINGS: Settings = {
dateLocale: "auto",
softWrap: true,
collapseNav: false,
automaticRedirect: "delayed",
automaticRedirect: true,
locale: "",
};
export const settings = useProfileStorage("settings", DEFAULT_SETTINGS);
// @ts-ignore: automaticRedirect is now a string enum, but might be a boolean in older data
if (settings.value.automaticRedirect === true) {
settings.value.automaticRedirect = "delayed";
// @ts-ignore: automaticRedirect is now a string enum, but might be a boolean in older data
} else if (settings.value.automaticRedirect === false) {
settings.value.automaticRedirect = "none";
}
export const {
collapseNav,
compact,
-1
View File
@@ -21,7 +21,6 @@ declare module 'vue-router/auto-routes' {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
'/container/[id]': RouteRecordInfo<'/container/[id]', '/container/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/container/[id].time.[datetime]': RouteRecordInfo<'/container/[id].time.[datetime]', '/container/:id/time/:datetime', { id: ParamValue<true>, datetime: ParamValue<true> }, { id: ParamValue<false>, datetime: ParamValue<false> }>,
'/group/[name]': RouteRecordInfo<'/group/[name]', '/group/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
'/host/[id]': RouteRecordInfo<'/host/[id]', '/host/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
-2
View File
@@ -16,8 +16,6 @@ export type ContainerJson = {
readonly status: string;
readonly state: ContainerState;
readonly host: string;
readonly cpuLimit: number;
readonly memoryLimit: number;
readonly labels: Record<string, string>;
readonly stats: ContainerStat[];
readonly health?: ContainerHealth;
+2 -35
View File
@@ -1,19 +1,10 @@
export function formatBytes(
bytes: number,
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
) {
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
if (short) {
return value + sizes[i].charAt(0);
} else {
return value + " " + sizes[i];
}
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function getDeep(obj: Record<string, any>, path: string[]) {
@@ -109,27 +100,3 @@ export function hashCode(str: string) {
}
return hash;
}
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31536000],
["month", 2592000],
["week", 604800],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1],
];
export function toRelativeTime(date: Date, locale: string | undefined): string {
const diffInSeconds = (date.getTime() - new Date().getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [unit, seconds] of units) {
const value = Math.round(diffInSeconds / seconds);
if (Math.abs(value) >= 1) {
return rtf.format(value, unit);
}
}
return rtf.format(0, "second");
}
+1 -1
View File
@@ -95,7 +95,7 @@ services:
playwright:
container_name: playwright
image: mcr.microsoft.com/playwright:v1.53.1-jammy
image: mcr.microsoft.com/playwright:v1.51.1-jammy
working_dir: /app
volumes:
- .:/app
-1
View File
@@ -72,7 +72,6 @@ export default defineConfig({
items: [
{ text: "Authentication", link: "/guide/authentication" },
{ text: "Actions", link: "/guide/actions" },
{ text: "Shell Access", link: "/guide/shell" },
{ text: "Agent Mode", link: "/guide/agent" },
{ text: "Changing Base", link: "/guide/changing-base" },
{ text: "Container Names", link: "/guide/container-names" },
+1 -1
View File
@@ -4,7 +4,7 @@ title: Container Actions
# Using Container Actions
Dozzle supports container actions, which allows you to `start`, `stop` and `restart` containers from the dropdown menu on the right next to the container stats. This feature is **disabled** by default and can be enabled by setting the environment variable `DOZZLE_ENABLE_ACTIONS` to `true`.
Dozzle supports Container Actions, which allows you to `start`, `stop` and `restart` containers from within the UI in the dropdown menu. This feature is **disabled** by default and can be enabled by setting the environment variable `DOZZLE_ENABLE_ACTIONS` to `true`.
::: code-group
+7 -22
View File
@@ -32,10 +32,6 @@ services:
:::
> [!NOTE] Docker Socket Proxy users
> If you are using a remote agent you **CANNOT** add a socket proxy on top of the agent. Dozzle agents **REPLACE** using a proxy, see [Remote Hosts](/guide/remote-hosts.md) for more info and how to use a socket proxy instead of an agent.
The agent will start and listen on port `7007`. You can connect to the agent using the Dozzle UI by providing the agent's IP address and port. The agent will only show the containers that are available on the host where the agent is running.
> [!TIP]
@@ -68,21 +64,10 @@ Note that when connecting remotely, you don't need to mount local Docker socket.
> [!TIP]
> You can connect to multiple agents by providing multiple `DOZZLE_REMOTE_AGENT` environment variables. For example, `DOZZLE_REMOTE_AGENT=agent1:7007,agent2:7007`.
## Common Issues
> [!WARNING]
> Dozzle uses the Docker API to gather information about hosts. Each agent needs a unique host ID. They use Docker's system ID or node ID to identify the host. If you are using swarm, then the node ID is used. If you don't see all hosts, then you may have duplicate hosts configured that have the same host ID. To fix this, remove `/var/lib/docker/engine-id` file. See [FAQ](/guide/faq#i-am-seeing-duplicate-hosts-error-in-the-logs-how-do-i-fix-it) for more information.
### Agent Not Showing Up
If you are seeing `An agent with an existing ID was found. Removing the duplicate host.` then you have two hosts that use the same Server ID.
Dozzle utilizes the Docker API to collect information about hosts. Each agent requires a unique host ID that remains consistent across restarts to ensure proper identification. Currently, agents identify the host using either Docker's system ID or node ID.
If you are operating in a Swarm environment, the node ID will be employed for this purpose. However, if you notice that not all hosts are visible, it may be due to the presence of duplicate hosts configured with the same host ID.
To resolve this issue, you should remove `/var/lib/docker/engine-id` from your system and restart. This action will help eliminate any conflicts caused by duplicate host IDs. For additional information and troubleshooting tips, please refer to the [FAQ](/guide/faq#i-am-seeing-duplicate-hosts-error-in-the-logs-how-do-i-fix-it).
## Advanced Options
### Setting Up Healthcheck
## Setting Up Healthcheck
You can set a healthcheck for the agent, similar to the healthcheck for the main Dozzle instance. When running in agent mode, healthcheck checks agent connection to Docker. If Docker is not reachable, the agent will be marked as unhealthy and will not be shown in the UI.
@@ -105,7 +90,7 @@ services:
- 7007:7007
```
### Changing Agent's Name
## Changing Agent's Name
Similar to Dozzle instance, you can change the agent's name by providing the `DOZZLE_HOSTNAME` environment variable. Here is an example:
@@ -132,7 +117,7 @@ services:
This will change the agent's name to `my-special-name` and will be reflected on the UI when connecting to the agent.
### Setting Up Filters
## Setting Up Filters
You can set up filters for the agent to limit the containers it can access. These filters are passed directly to Docker, restricting what Dozzle can view.
@@ -149,7 +134,7 @@ services:
This will restrict the agent to displaying only containers with the label `color`. Keep in mind that these filters are combined with the UI filters to narrow down the containers. To learn more about the different types of filters, read the [filters documentation](/guide/filters#ui-agents-and-user-filters).
### Custom Certificates
## Custom Certificates
By default, Dozzle uses self-signed certificates for communication between agents. This is a private certificate which is only valid to other Dozzle instances. This is secure and recommended for most use cases. However, if Dozzle is exposed externally and an attacker knows exactly which port the agent is running on, then they can set up their own Dozzle instance and connect to the agent. To prevent this, you can provide your own certificates.
@@ -184,7 +169,7 @@ This will mount the `cert.pem` and `key.pem` files to the agent. The agent will
To generate certificates, you can use the following command:
```sh
$ openssl genpkey -algorithm Ed25519 -out key.pem
$ openssl genpkey -algorithm RSA -out key.pem -pkeyopt rsa_keygen_bits:2048
$ openssl req -new -key key.pem -out request.csr -subj "/C=US/ST=California/L=San Francisco/O=My Company"
$ openssl x509 -req -in request.csr -signkey key.pem -out cert.pem -days 365
```
+5 -10
View File
@@ -10,12 +10,7 @@ If you do not have an authentication solution, then Dozzle has a simple file-bas
## File-Based User Management
Dozzle supports multi-user authentication by setting `--auth-provider` to `simple`. In this mode, Dozzle will attempt to read the users file from `/data/`, prioritizing `users.yml` over `users.yaml` if both files are present. If only one of the files exists, it will be used. The log will indicate which file is being read (e.g., `Reading users.yml file`).
### Example file paths:
- `/data/users.yml`
- `/data/users.yaml`
Dozzle supports multi-user authentication by setting `--auth-provider` to `simple`. In this mode, Dozzle will try to read `/data/users.yml`.
The content of the file looks like:
@@ -25,7 +20,7 @@ users:
admin:
email: me@email.net
name: Admin
# Generate with docker run -it --rm amir20/dozzle generate --name Admin --email me@email.net --password secret admin
# Generate with docker run amir20/dozzle generate --name Admin --email me@email.net --password secret admin
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
filter:
```
@@ -33,7 +28,7 @@ users:
Dozzle uses `email` to generate avatars using [Gravatar](https://gravatar.com/). It is optional. The password is hashed using `bcrypt` which can be generated using `docker run amir20/dozzle generate`.
> [!WARNING]
> In previous versions of Dozzle, SHA-256 was used to hash passwords. Bcrypt is now more secure and is recommended for future use. Dozzle will revert to SHA-256 if it does not find a bcrypt hash. It is advisable to update the password hash to bcrypt using `generate`. For more details, see [this issue](https://github.com/amir20/dozzle/security/advisories/GHSA-w7qr-q9fh-fj35).
> In previous versions of Dozzle, SHA-256 was used to hash passwords. Bcrypt is now more secure and is recommended for future use. Dozzle will revert to SHA-256 if it does not find a bcrypt hash. It is advisable to update the password hash to bcrypt using `docker run amir20/dozzle generate`. For more details, see [this issue](https://github.com/amir20/dozzle/security/advisories/GHSA-w7qr-q9fh-fj35).
You will need to mount this file for Dozzle to find it. Here is an example:
@@ -144,10 +139,10 @@ In this example, the `admin` user has no filter, so they can see all containers.
Dozzle has a built-in `generate` command to generate `users.yml`. Here is an example:
```sh
docker run -it --rm amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" --user-filter name=foo > users.yml
docker run amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" --user-filter name=foo > users.yml
```
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters.
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters.
## Forward Proxy
-3
View File
@@ -42,8 +42,5 @@ location ^~ /foobar/ {
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
+3 -19
View File
@@ -28,22 +28,11 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 8080:8080
environment:
# Uncomment to enable container actions (stop, start, restart). See https://dozzle.dev/guide/actions
# - DOZZLE_ENABLE_ACTIONS=true
#
# Uncomment to allow access to container shells. See https://dozzle.dev/guide/shell
# - DOZZLE_ENABLE_SHELL=true
#
# Uncomment to enable authentication. See https://dozzle.dev/guide/authentication
# - DOZZLE_AUTH_PROVIDER=simple
```
:::
> [!TIP]
> Dozzle supports actions, such as stopping, starting, and restarting containers, or attaching to container shells. But they are disabled by default for security reasons. To enable them, uncomment the corresponding environment variables.
> Dozzle also supports connecting to remote agents to monitor multiple Docker hosts. See [agent](/guide/agent) to learn more.
Dozzle also supports connecting to remote agents to monitor multiple Docker hosts. See [agent](/guide/agent) to learn more.
## Docker Swarm
@@ -81,9 +70,6 @@ See [swarm mode](/guide/swarm-mode) for more information.
Dozzle supports running in Kubernetes. It only needs to be deployed on one node within the cluster.
<details>
<summary>Kubernetes Configuration</summary>
```yaml [k8s-dozzle.yml]
# rbac.yaml
apiVersion: v1
@@ -144,11 +130,9 @@ spec:
value: "k8s"
```
</details>
Then apply the configuration using the following command:
Apply the configuration using the following command:
```sh
```bash
kubectl apply -f k8s-dozzle.yml
```
-3
View File
@@ -139,9 +139,6 @@ spec:
value: "default"
```
> [!NOTE]
> Dozzle supports multiple namespaces, you can set the `DOZZLE_NAMESPACES` environment variable to a comma separated list of namespaces. When multiple namespaces are specified, Dozzle will monitor each namespace separately and combine the results.
### Labels and Filters
`DOZZLE_FILTER` behave similarlty to Docker filters. You can limit the scope of Dozzle using the `DOZZLE_FILTER` environment variable. For example, to scope only to `env=prod`:
+1 -1
View File
@@ -37,6 +37,6 @@ services:
:::
In the above example, `/var/log/system.log` is mounted from the host and used with `tail -f` to follow the file. `tail` is smart to follow log rotations. During testing, using Alpine used about `~50KB` of memory.
In the above example, `/var/log/system.log` is mounted from the host and used with `tail -f` to follow the file. `tail` is smart to follow log rotations. During testing, using Alpine only uses about `300KB` of memory.
The second tab shows a `docker-compose` file which is useful if you want the log stream to survive server reboot.
+1 -3
View File
@@ -8,8 +8,6 @@ Dozzle supports connecting to remote Docker hosts. This is useful when running D
However, with Dozzle agents, you can connect to remote hosts without exposing the Docker socket. See the [agent](/guide/agent) page for more information.
Dozzle agents remove the need to remotely expose the Docker socket but cannot be uses with a Docker Socket proxy inside the Dozzle agent stack. If you wish to use a Socket Proxy on it's own without an agent see the [connecting with a socket proxy](#connecting-with-a-socket-proxy) section.
> [!WARNING]
> Remote hosts have been replaced with agents. Agents provide a more secure way to connect to remote hosts. Although remote hosts are still supported, it is recommended to use agents. See the [agent](/guide/agent) page for more information and examples. For comparison, see the [comparing agents with remote connections](/guide/agent#comparing-agents-with-remote-connection) section. I won't be able to investigate user's issues with remote hosts as it is very time consuming.
@@ -44,7 +42,7 @@ services:
## Connecting with a Socket Proxy
If you are in a private network, then you can use [Docker Socket Proxy](https://github.com/Tecnativa/docker-socket-proxy) which exposes `docker.sock` file without the need for TLS. This will remove the need for a Dozzle agent and Dozzle will connect directly to the Socket Proxy instead. Dozzle will never try to write to Docker but it will need access to list APIs. The following command will start a proxy with minimal access:
If you are in a private network, then you can use [Docker Socket Proxy](https://github.com/Tecnativa/docker-socket-proxy) which exposes `docker.sock` file without the need for TLS. Dozzle will never try to write to Docker but it will need access to list APIs. The following command will start a proxy with minimal access:
```sh
$ docker container run --privileged -e CONTAINERS=1 -e INFO=1 -v /var/run/docker.sock:/var/run/docker.sock -p 2375:2375 tecnativa/docker-socket-proxy
-30
View File
@@ -1,30 +0,0 @@
---
title: Container Shell Access
---
# Attaching and Executing Commands <Badge type="info" text="new" />
Dozzle supports attaching or executing commands within containers. It provides a web-based interface to interact with Docker containers, allowing users to attach to running containers and execute commands directly from the browser. This feature is particularly useful for debugging and troubleshooting containerized applications. This feature is **disabled** by default as it may pose security risks. To enable it, set the `DOZZLE_ENABLE_SHELL` environment variable to `true`.
::: code-group
```sh
docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --enable-shell
```
```yaml [docker-compose.yml]
services:
dozzle:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 8080:8080
environment:
DOZZLE_ENABLE_SHELL: true
```
:::
> [!NOTE]
> Shell access should work across all container types, including Docker, Kubernetes, and other orchestration platforms.
+2 -3
View File
@@ -17,7 +17,6 @@ Configurations can be done with flags or environment variables. The table below
| `--auth-header-email` | `DOZZLE_AUTH_HEADER_EMAIL` | `Remote-Email` |
| `--auth-header-name` | `DOZZLE_AUTH_HEADER_NAME` | `Remote-Name` |
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
| `--enable-shell` | `DOZZLE_ENABLE_SHELL` | `false` |
| `--filter` | `DOZZLE_FILTER` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
| `--mode` | `DOZZLE_MODE` | `server` |
@@ -27,14 +26,14 @@ Configurations can be done with flags or environment variables. The table below
| `--namespace` | `DOZZLE_NAMESPACE` | `""` |
> [!TIP]
> Some flags like `--remote-host` or `--remote-agent` can be used multiple times. For example, `--remote-agent 167.99.1.1:7007 --remote-agent 167.99.1.2:7007` or comma-separated `DOZZLE_REMOTE_AGENT=167.99.1.1:7007,167.99.1.2:7007`.
> Some flags like `--remote-host` or `--remote-agent` can be used multiple times. For example, `--remote-agent tcp://167.99.1.1:7007 --remote-agent tcp://167.99.1.2:7007` or comma-separated `DOZZLE_REMOTE_AGENT=tcp://167.99.1.1:7007,tcp://167.99.1.2:7007`.
## Generate users.yml
Dozzle supports generating `users.yml` file. This file is used to authenticate users. Here is an example:
```sh
docker run -it --rm amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" > users.yml
docker run amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" > users.yml
```
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run amir20/dozzle generate --help` displays all options.
+2 -2
View File
@@ -37,8 +37,8 @@ networks:
Note that the `DOZZLE_MODE` environment variable is set to `swarm`. This tells Dozzle to automatically discover other Dozzle instances in the swarm. The `overlay` network is used to create the mesh network between the different Dozzle instances.
> [!WARNING]
> Socket-proxy cannot be used in Docker Swarm mode. This limitation stems from Docker itself, not Dozzle. In Swarm mode, services can only communicate with other services, but Dozzle requires direct connections to individual proxy instances—which isn't supported. If you have a solution for using socket-proxy in Swarm mode, we'd love to hear from you!
> [!NOTE]
> Due to implementation details, <strike>the name for the service must be exactly `dozzle`</strike>. This is no longer required starting with version `v8.2`. You can name the service anything you want. The service name is automatically detected by Dozzle using `com.docker.swarm.service.name` label.
## Setting Up Simple Authentication in Swarm Mode

Some files were not shown because too many files have changed in this diff Show More