Compare commits

..

1 Commits

Author SHA1 Message Date
Amir Raminfar f8fbd796b7 chore: updates mods 2025-03-07 08:41:50 -08:00
210 changed files with 4997 additions and 8238 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/dozzle"
cmd = "GOEXPERIMENT=jsonv2 go build -race -o ./tmp/dozzle ."
cmd = "go build -race -o ./tmp/dozzle ."
delay = 1000
exclude_dir = [
"assets",
@@ -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
+16 -22
View File
@@ -8,16 +8,14 @@ jobs:
name: JavaScript Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
name: Install Node
with:
node-version: latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: latest
cache: "pnpm"
@@ -31,12 +29,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: 1.25.2
go-version: 1.22.x
check-latest: true
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: Install gRPC and Go
@@ -50,16 +48,14 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
name: Install Node
with:
node-version: latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: latest
cache: "pnpm"
@@ -94,20 +90,20 @@ 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.6.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -125,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: .
@@ -141,13 +137,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
name: Install pnpm
- name: Install Node
uses: actions/setup-node@v5
uses: actions/setup-node@v4
- name: Release to Github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -13,20 +13,20 @@ 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.6.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -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
+3 -3
View File
@@ -24,12 +24,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0 # Not needed if lastUpdated is not enabled
- uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: latest
cache: pnpm # or pnpm / yarn
@@ -42,7 +42,7 @@ jobs:
pnpm docs:build
touch docs/.vitepress/dist/.nojekyll
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
+1 -1
View File
@@ -15,6 +15,6 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+16 -22
View File
@@ -11,16 +11,14 @@ jobs:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
name: Install Node
with:
node-version: latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: latest
cache: "pnpm"
@@ -33,16 +31,14 @@ jobs:
name: JavaScript Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
name: Install Node
with:
node-version: latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: latest
cache: "pnpm"
@@ -56,12 +52,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.25.2"
go-version: "1.23"
check-latest: true
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
@@ -77,16 +73,16 @@ jobs:
name: Go Staticcheck
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.25.2"
go-version: "1.24"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
- name: Stactic checker
uses: dominikh/staticcheck-action@v1.4.0
uses: dominikh/staticcheck-action@v1.3.1
with:
install-go: false
int-test:
@@ -94,16 +90,14 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
name: Install Node
with:
node-version: latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: latest
cache: "pnpm"
+3 -4
View File
@@ -1,5 +1,5 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:23.11.1-alpine AS node
FROM --platform=$BUILDPLATFORM node:23.9.0-alpine AS node
RUN corepack enable
@@ -22,7 +22,7 @@ COPY public ./public
# Build assets
RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
# install gRPC dependencies
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
@@ -54,14 +54,13 @@ ARG TARGETOS TARGETARCH
RUN go generate
# Build binary
RUN GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
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 -4
View File
@@ -9,9 +9,6 @@ https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
[![Docker Version](https://img.shields.io/docker/v/amir20/dozzle?sort=semver)](https://hub.docker.com/r/amir20/dozzle/)
![Test](https://github.com/amir20/dozzle/workflows/Test/badge.svg)
> [!NOTE]
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop) which is a top like application for monitoring Docker containers. It integrates with Dozzle to allow for linking directly to container logs.
## Features
- Intelligent fuzzy search for container names 🤖
@@ -118,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
View File
@@ -15,7 +15,6 @@ watchEffect(() => {
if (theme === "auto") {
theme = mode.value;
}
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme == "dark" ? "#121212" : "#F5F5F5");
document.documentElement.setAttribute("data-theme", theme);
});
</script>
+1 -15
View File
@@ -31,7 +31,6 @@ declare global {
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
@@ -53,7 +52,6 @@ declare global {
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const getDeep: typeof import('./utils/index')['getDeep']
const globalShowPopup: typeof import('./composable/popup')['globalShowPopup']
const h: typeof import('vue')['h']
@@ -69,9 +67,7 @@ declare global {
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
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']
@@ -154,7 +150,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']
@@ -242,7 +237,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']
@@ -332,7 +326,6 @@ declare global {
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
@@ -379,7 +372,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, ShallowRef, 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'
@@ -428,7 +421,6 @@ declare module 'vue' {
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
@@ -450,7 +442,6 @@ declare module 'vue' {
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly h: UnwrapRef<typeof import('vue')['h']>
@@ -466,9 +457,7 @@ declare module 'vue' {
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
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']>
@@ -551,7 +540,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']>
@@ -639,7 +627,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']>
@@ -729,7 +716,6 @@ declare module 'vue' {
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
+5 -19
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,59 +36,48 @@ 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:person': typeof import('~icons/material-symbols/person')['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']
@@ -114,8 +103,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']
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
@@ -134,7 +121,6 @@ declare module 'vue' {
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
Tag: typeof import('./components/common/Tag.vue')['default']
Terminal: typeof import('./components/Terminal.vue')['default']
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
Toggle: typeof import('./components/common/Toggle.vue')['default']
+4 -8
View File
@@ -1,9 +1,5 @@
<template>
<Dropdown
class="dropdown-end"
@click="config.releaseCheckMode === 'manual' && fetchReleases()"
@closed="releaseSeen = mostRecent?.tag ?? config.version"
>
<Dropdown class="dropdown-end" @closed="releaseSeen = mostRecent?.tag ?? config.version">
<template #trigger>
<mdi:announcement class="size-6 -rotate-12" />
<template v-if="announcements.length > 0 && releaseSeen != mostRecent?.tag">
@@ -26,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 }}
@@ -43,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>
@@ -67,7 +63,7 @@
<script setup lang="ts">
import { useAnnouncements } from "@/stores/announcements";
const { announcements, mostRecent, fetchReleases } = useAnnouncements();
const { announcements, mostRecent } = useAnnouncements();
const { t } = useI18n();
const releaseSeen = useProfileStorage("releaseSeen", config.version);
-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,31 +1,27 @@
<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>
</li>
<li v-if="enableDownload">
<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
@@ -138,24 +134,6 @@
</button>
</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>
</ul>
</div>
</template>
@@ -164,14 +142,13 @@
import { Container } from "@/models/Container";
import { allLevels } from "@/composable/logContext";
import LogAnalytics from "../LogViewer/LogAnalytics.vue";
import Terminal from "@/components/Terminal.vue";
const { showSearch } = useSearchFilter();
const { enableActions, enableShell, enableDownload } = 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 +160,6 @@ 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();
}
});
}
const downloadParams = computed(() =>
Object.entries(toValue(streamConfig))
@@ -223,20 +185,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>
@@ -66,10 +62,38 @@ const store = useContainerStore();
const { containers: allContainers } = storeToRefs(store);
const otherContainers = computed(() =>
allContainers.value
.filter((c) => c.name === container.name && c.id !== container.id)
.sort((a, b) => +b.created - +a.created),
[...allContainers.value.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>
+12 -26
View File
@@ -14,31 +14,23 @@
<dropdown class="dropdown-end" v-if="config.user">
<template #trigger>
<template v-if="config.disableAvatars || !config.user.email">
<material-symbols:person class="size-6" />
</template>
<template v-else>
<img
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
:src="withBase('/api/profile/avatar')"
/>
</template>
<img
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
:src="withBase('/api/profile/avatar')"
/>
</template>
<template #content>
<div class="p-2">
<div class="font-bold">
{{ config.user.name }}
</div>
<div v-if="config.user.email" class="text-sm font-light">
<div class="text-sm font-light">
{{ config.user.email }}
</div>
</div>
<ul v-if="config.authProvider === 'simple' || config.logoutUrl" class="menu mt-4 p-0">
<li>
<button @click.prevent="logout()" class="text-primary p-2">
<material-symbols:logout />
{{ $t("button.logout") }}
</button>
<ul class="menu mt-4 p-0">
<li v-if="config.authProvider === 'simple'">
<button @click.prevent="logout()" class="text-primary p-2">{{ $t("button.logout") }}</button>
</li>
</ul>
</template>
@@ -46,17 +38,11 @@
</div>
</template>
<script lang="ts" setup>
const { logoutUrl } = config;
async function logout() {
if (logoutUrl) {
location.href = logoutUrl;
} else {
await fetch(withBase("/api/token"), {
method: "DELETE",
});
await fetch(withBase("/api/token"), {
method: "DELETE",
});
location.reload();
}
location.reload();
}
</script>
+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-right 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>>>;
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<Tag class="items-start!">
<Tag size="small" class="items-start!">
<DateTime :date="date" class="text-blue whitespace-nowrap" />
</Tag>
</template>
+14 -16
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)" />
@@ -72,6 +72,7 @@
<script setup lang="ts">
import { ComplexLogEntry } from "@/models/LogEntry";
import { useSortable } from "@vueuse/integrations/useSortable";
import { UseClipboard } from "@vueuse/components";
const { entry } = defineProps<{ entry: ComplexLogEntry }>();
@@ -81,8 +82,6 @@ const container = currentContainer(toRef(() => entry.containerID));
const visibleKeys = persistentVisibleKeysForContainer(container);
const { hosts } = useHosts();
const { useSortable } = await import("@vueuse/integrations/useSortable");
function toggleField(key: string[]) {
if (visibleKeys.value.size === 0) {
visibleKeys.value = new Map<string[], boolean>(fields.value.map(({ key }) => [key, true]));
@@ -96,15 +95,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,14 +140,14 @@ 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) {
var cls = "json-number";
if (match.startsWith('"')) {
if (match.endsWith(":")) {
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = "json-key";
} else {
cls = "json-string";
@@ -167,7 +165,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>
+11 -15
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;
}
@@ -82,7 +82,7 @@ ul {
}
&.large {
@apply text-[1em];
@apply text-lg;
}
&.compact {
@@ -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>
+2 -2
View File
@@ -1,5 +1,5 @@
<template>
<Tag :std="std" class="items-start!">
<Tag size="small" :std="std" class="items-start!">
{{ std }}
</Tag>
</template>
@@ -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,25 +1,21 @@
<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>
</li>
<li v-if="enableDownload">
<li>
<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>
@@ -88,7 +84,7 @@
<script lang="ts" setup>
const { showSearch } = useSearchFilter();
const { enableDownload } = config;
const clear = defineEmit();
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();
@@ -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>
+11 -17
View File
@@ -1,31 +1,25 @@
<template>
<div class="hover:text-primary 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="hover:text-secondary relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
<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-right 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">
<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-right 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">
<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-right 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">
<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-20 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 });
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<transition name="slide">
<div
class="fixed z-50 flex w-full justify-end p-2"
class="fixed z-10 flex w-full justify-end p-2"
v-show="showSearch"
v-if="search"
ref="container"
@@ -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>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 flex-col w-full">
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 flex-col">
<Carousel v-model="selectedCard" class="flex-1">
<CarouselItem :title="$t('label.host-menu')" id="host">
<HostMenu />
+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>
-95
View File
@@ -1,95 +0,0 @@
<template>
<aside>
<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>
</header>
<div class="mt-8 flex flex-col gap-2">
<section>
<div ref="host" class="shell"></div>
</section>
</div>
</aside>
</template>
<script setup lang="ts">
import { Container } from "@/models/Container";
import "@xterm/xterm/css/xterm.css";
const { container, action } = defineProps<{ container: Container; action: "attach" | "exec" }>();
const { Terminal } = await import("@xterm/xterm");
const { WebLinksAddon } = await import("@xterm/addon-web-links");
const host = useTemplateRef<HTMLDivElement>("host");
const terminal = new Terminal({
cursorBlink: true,
cursorStyle: "block",
});
terminal.loadAddon(new WebLinksAddon());
let ws: WebSocket | null = null;
onMounted(() => {
terminal.open(host.value!);
terminal.resize(100, 40);
ws = new WebSocket(withBase(`/api/hosts/${container.host}/containers/${container.id}/${action}`));
ws.onopen = () => {
terminal.writeln(`Attaching to ${container.name} 🚀`);
if (action === "attach") {
ws?.send("\r");
}
terminal.onData((data) => {
ws?.send(data);
});
terminal.focus();
};
ws.onmessage = (event) => terminal.write(event.data);
ws.addEventListener("close", () => {
terminal.writeln("⚠️ Connection closed");
});
});
onUnmounted(() => {
console.log("Closing WebSocket");
terminal.dispose();
ws?.close();
});
</script>
<style scoped>
@reference "@/main.css";
.shell {
& :deep(.terminal) {
@apply overflow-hidden rounded border-1 p-2;
&:is(.focus) {
@apply border-primary;
}
}
& :deep(.xterm-viewport) {
@apply bg-base-200!;
}
& :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 swap-rotate 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,
}),
);
+1 -1
View File
@@ -29,7 +29,7 @@ export function useProfileStorage<K extends keyof Profile>(
if (transformer) {
storage.value = transformer.from(config.profile[key]);
} else if (storage.value instanceof Set && config.profile[key] instanceof Array) {
storage.value = new Set(config.profile[key] as Iterable<any>) as unknown as NonNullable<Profile[K]>;
storage.value = new Set([...(config.profile[key] as Iterable<any>)]) as unknown as NonNullable<Profile[K]>;
} else if (config.profile[key] instanceof Array) {
storage.value = config.profile[key] as NonNullable<Profile[K]>;
} else if (config.profile[key] instanceof Object) {
+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(),
+13 -9
View File
@@ -1,11 +1,11 @@
<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" />
</Pane>
<Pane min-size="10" :size="100 - menuWidth">
<Pane min-size="10">
<Splitpanes>
<Pane class="router-view min-h-screen">
<router-view></router-view>
@@ -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>
@@ -87,18 +91,18 @@ function showFuzzySearch() {
open.value = true;
}
function onResized({ panes }: { panes: { size: number }[] }) {
if (panes.length == 2) {
menuWidth.value = Math.min(panes[0].size, 50);
function onResized(e: any) {
if (e.length == 2) {
menuWidth.value = e[0].size;
}
}
</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];
@apply bg-base-100 hover:bg-secondary min-w-[3px];
}
@media screen and (max-width: 768px) {
+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

+1 -22
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;
@@ -136,14 +126,3 @@
}
}
}
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;
}
+1 -14
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,
@@ -80,11 +71,7 @@ export class Container {
}
get namespace() {
return (
this.labels["dev.dozzle.group"] ||
this.labels["com.docker.stack.namespace"] ||
this.labels["com.docker.compose.project"]
);
return this.labels["com.docker.stack.namespace"] || this.labels["com.docker.compose.project"];
}
get customGroup() {
+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 -20
View File
@@ -12,25 +12,7 @@ type Announcement = {
breaking: number;
};
const releases = ref<Announcement[]>([]);
let fetched = false;
async function fetchReleases() {
if (fetched) return;
fetched = true;
try {
const { data } = await useFetch(withBase("/api/releases")).get().json<Announcement[]>();
releases.value = data.value || [];
} catch (error) {
console.error("Error while fetching releases:\n", error);
fetched = false;
}
}
if (config.releaseCheckMode === "automatic") {
fetchReleases();
}
const { data: releases } = useFetch(withBase("/api/releases")).get().json<Announcement[]>();
const otherAnnouncements = [] as Announcement[];
@@ -51,6 +33,5 @@ export function useAnnouncements() {
announcements,
latestRelease,
hasRelease,
fetchReleases,
};
}
-5
View File
@@ -10,12 +10,7 @@ export interface Config {
hostname: string;
hosts: Host[];
authProvider: "simple" | "none" | "forward-proxy";
logoutUrl?: string;
enableActions: boolean;
enableShell: boolean;
enableDownload: boolean;
disableAvatars: boolean;
releaseCheckMode: "automatic" | "manual";
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,
-73
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>>,
@@ -31,76 +30,4 @@ declare module 'vue-router/auto-routes' {
'/show': RouteRecordInfo<'/show', '/show', Record<never, never>, Record<never, never>>,
'/stack/[name]': RouteRecordInfo<'/stack/[name]', '/stack/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
}
/**
* Route file to route info map by unplugin-vue-router.
* Used by the volar plugin to automatically type useRoute()
*
* Each key is a file path relative to the project root with 2 properties:
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
* - views: names of nested views (can be passed to <RouterView name="...">)
*
* @internal
*/
export interface _RouteFileInfoMap {
'assets/pages/index.vue': {
routes: '/'
views: never
}
'assets/pages/[...all].vue': {
routes: '/[...all]'
views: never
}
'assets/pages/container/[id].vue': {
routes: '/container/[id]'
views: never
}
'assets/pages/container/[id].time.[datetime].vue': {
routes: '/container/[id].time.[datetime]'
views: never
}
'assets/pages/group/[name].vue': {
routes: '/group/[name]'
views: never
}
'assets/pages/host/[id].vue': {
routes: '/host/[id]'
views: never
}
'assets/pages/login.vue': {
routes: '/login'
views: never
}
'assets/pages/merged/[ids].vue': {
routes: '/merged/[ids]'
views: never
}
'assets/pages/service/[name].vue': {
routes: '/service/[name]'
views: never
}
'assets/pages/settings.vue': {
routes: '/settings'
views: never
}
'assets/pages/show.vue': {
routes: '/show'
views: never
}
'assets/pages/stack/[name].vue': {
routes: '/stack/[name]'
views: never
}
}
/**
* Get a union of possible route names in a certain route component file.
* Used by the volar plugin to automatically type useRoute()
*
* @internal
*/
export type _RouteNamesForFilePath<FilePath extends string> =
_RouteFileInfoMap extends Record<FilePath, infer Info>
? Info['routes']
: keyof RouteNamedMap
}
-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.56.0-jammy
image: mcr.microsoft.com/playwright:v1.51.0-jammy
working_dir: /app
volumes:
- .:/app
-2
View File
@@ -57,7 +57,6 @@ export default defineConfig({
items: [
{ text: "What is Dozzle?", link: "/guide/what-is-dozzle" },
{ text: "Getting Started", link: "/guide/getting-started" },
{ text: "Introducing dtop 🚀", link: "/guide/dtop" },
],
},
{
@@ -73,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 -135
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,16 +20,15 @@ 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:
roles:
```
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:
@@ -140,44 +134,15 @@ In this example, the `admin` user has no filter, so they can see all containers.
> [!NOTE]
> Filters can also be set [globally](/guide/filters) with the `--filter` flag. This flag is applied to all users. If a user has a filter set, it will override the global filter.
### Setting specific roles for users
Dozzle allows assigning roles to users. Roles define what actions a user can perform on containers. Roles are configured in the users.yml file.
```yaml
users:
admin:
email:
name: Admin
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
roles:
guest:
email:
name: Guest
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
roles: shell
```
In this example, the `admin` user has no roles specified, so they have full access to all container actions. The `guest` user has the shell role, meaning they can only open a shell in the containers. Roles make it easy to control and restrict what users can do in Dozzle.
Dozzle supports the following roles:
- **shell** - allows attach and exec in the container
- **actions** - allows performing container actions (start, stop, restart)
- **download** - allows downloading container logs
- **none** - denies all actions
- **all** - allows all actions (default)
## Generating users.yml
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 --user-roles shell > 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. The `--user-roles` flag is a comma-separated list of roles.
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
@@ -209,21 +174,11 @@ In this mode, Dozzle expects the following headers:
- `Remote-Email` to map to the user's email address. This email is also used to find the right [Gravatar](https://gravatar.com/) for the user.
- `Remote-Name` to be a display name like `John Doe`
- `Remote-Filter` to be a comma-separated list of filters allowed for user.
- `Remote-Roles` to be a comma-separated list of roles allowed for user.
Additionally, you can configure a logout URL with:
```yaml
DOZZLE_AUTH_LOGOUT_URL: http://oauth2.example.ru/oauth2/sign_out
```
### Setting up Dozzle with Authelia
[Authelia](https://www.authelia.com/) is an open-source authentication and authorization server and portal fulfilling the identity and access management. While setting up Authelia is out of scope for this section, the configuration can be shared as an example for setting up Dozzle with Authelia.
<details>
<summary>➡️ Click to expand Authelia example</summary>
::: code-group
```yaml [docker-compose.yml]
@@ -358,8 +313,6 @@ notifier:
Valid SSL keys are required because Authelia only supports SSL.
</details>
### Setting up Dozzle with Cloudflare Zero Trust
Cloudflare Zero Trust is a service for authenticated access to self-hosted software. This section defines how Dozzle can be set up to use Cloudflare Zero Trust for authentication.
@@ -383,86 +336,3 @@ services:
```
After running the Dozzle container, configure the Application in Cloudflare Zero Trust dashboard by following the [guide](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/).
### Setting up Dozzle with Pocket ID
You must first setup a container to pass OpenID Connect authentication through your reverse proxy.
Below is an example using [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy).
<details>
<summary>➡️ Click to expand oauth2-proxy example</summary>
1. Create a new OIDC client in Pocket ID for Dozzle:
- **Name:** `Dozzle`
- **Callback URLs:** `https://dozzle.example.com/oauth2/callback`
- **PKCE:** `Enabled`
Copy the **Client ID** and **Client Secret** values for use later.
2. Add the following to your existing Dozzle compose:
```yml
environment:
DOZZLE_AUTH_PROVIDER: forward-proxy
DOZZLE_AUTH_HEADER_USER: X-Forwarded-User
DOZZLE_AUTH_HEADER_EMAIL: X-Forwarded-Email
DOZZLE_AUTH_HEADER_NAME: X-Forwarded-Preferred-Username
```
Comment out the Dozzle ports, as we will redirect these through the new authentication container.
This method should not require any changes to your reverse proxy configuration.
```yml
# ports:
# - 8080:8080
```
3. Add a new oauth2-proxy container service to your existing Dozzle compose:
```yml
services:
# ...
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
restart: unless-stopped
container_name: dozzle-oidc
command: --config /oauth2-proxy.cfg
volumes:
- "./oauth2-proxy.cfg:/oauth2-proxy.cfg"
ports:
- 8080:4180
```
4. Create the oauth2-proxy config file.
In the directory beside your compose file, create `oauth2-proxy.cfg` :
```toml
client_id = "xxx" # from Pocket ID
client_secret = "xxx" # from Pocket ID
cookie_secret = "xxx" # generate with openssl rand -base64 32 | tr -- '+/' '-_'
upstreams = "http://dozzle:8080" # upstream to Dozzle containers internal port
code_challenge_method = "S256" # PKCE challenges plain or S256
cookie_expire = "0" # seconds, 0 for session
cookie_name = "__Host-oauth2-proxy" # or __Secure-oauth2-proxy (less secure)
cookie_secure = true # uses the secure HTTPS cookie
email_domains = ["*"] # allows any email domain to authenticate
http_address = "0.0.0.0:4180" # port oauth2-proxy listens on
oidc_issuer_url = "https://id.example.com" # your Pocket base URL
provider_display_name = "Pocket ID" # display name for OIDC login
provider = "oidc" # use OpenID connect
reverse_proxy = true # reverse proxy the traffic
scope = "openid email profile groups" # passthru these OIDC scopes
```
Fill in the variables per the comments.
5. Finally - restart your Docker compose stack.
Your reverse proxy should now authenticate you to Dozzle via oauth2-proxy.
Check logs for troubleshooting.
</details>
-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";
}
```

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