Compare commits
51 Commits
v8.14.10
...
notification
| Author | SHA1 | Date | |
|---|---|---|---|
| f393d0d3e6 | |||
| 9988271490 | |||
| 05f76c4f57 | |||
| 20c1adbd7e | |||
| d44ab349b9 | |||
| 21367bcc36 | |||
| 6ece0df082 | |||
| b087f30fb9 | |||
| 5e65e2fca5 | |||
| 2303fbdfd3 | |||
| 078f4c4064 | |||
| 5be1a55d33 | |||
| 09b1bae3cd | |||
| 2a0037a667 | |||
| e00752b0bb | |||
| 497e474471 | |||
| 753d45d7af | |||
| 9a2d6fc6e8 | |||
| 4dcaa43597 | |||
| b51ec5bfa7 | |||
| 8c8ac09521 | |||
| 2537e16b6c | |||
| a8a76e29c9 | |||
| 65b2ac9181 | |||
| ddc4e310a8 | |||
| 0a2afcf93c | |||
| 41cc1eb2ff | |||
| 2a63d300a4 | |||
| fb9265204d | |||
| 41d46f95b3 | |||
| d6ad628769 | |||
| 8c4b103468 | |||
| 9ee66dbe80 | |||
| 55aeb46262 | |||
| 1b260019a1 | |||
| e07d97a28a | |||
| 7b53cffdae | |||
| d630056a61 | |||
| 1f712b56e0 | |||
| c199e1fa28 | |||
| 60c8f7a255 | |||
| 6bafcc6f77 | |||
| 529fb3ad6d | |||
| 8000b6c14e | |||
| b74dc9f58a | |||
| 67156b501f | |||
| fd69eae2ea | |||
| 9530bfeadc | |||
| 1d6b4293e7 | |||
| 940bcde6c6 | |||
| 836c179b4c |
@@ -14,12 +14,12 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.25.4
|
||||
go-version: 1.25.5
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.6.0
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.6.0
|
||||
with:
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.4"
|
||||
go-version: "1.25.5"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.4"
|
||||
go-version: "1.25.5"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: 24.12.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -147,8 +147,18 @@ The frontend uses file-based routing with these conventions:
|
||||
|
||||
- **`assets/components/`** - Vue components (auto-imported)
|
||||
- `LogViewer/`: Core log viewing components
|
||||
- `SimpleLogItem.vue`: Single-line log entries
|
||||
- `ComplexLogItem.vue`: JSON/structured log entries
|
||||
- `GroupedLogItem.vue`: Multi-line grouped log entries
|
||||
- `ContainerEventLogItem.vue`: Container lifecycle events
|
||||
- `SkippedEntriesLogItem.vue`: Placeholder for skipped logs
|
||||
- `LoadMoreLogItem.vue`: Load more historical logs
|
||||
- `ContainerViewer/`: Container-specific UI
|
||||
- `common/`: Reusable UI components
|
||||
- `BarChart.vue`: Lightweight bar chart with automatic downsampling
|
||||
- `HostCard.vue`: Host overview card with metrics
|
||||
- `MetricCard.vue`: Reusable metric display component
|
||||
- `ContainerTable.vue`: Container table with historical stat visualization
|
||||
|
||||
- **`assets/stores/`** - Pinia stores (auto-imported)
|
||||
- `config.ts`: App configuration and feature flags
|
||||
@@ -161,6 +171,7 @@ The frontend uses file-based routing with these conventions:
|
||||
- `historicalLogs.ts`: Historical log fetching
|
||||
- `logContext.ts`: Log filtering and search context
|
||||
- `storage.ts`: LocalStorage abstractions
|
||||
- `visible.ts`: Log filtering by visible keys for complex logs
|
||||
|
||||
- **`assets/modules/`** - Vue plugins
|
||||
- `router.ts`: Vue Router configuration
|
||||
@@ -190,6 +201,15 @@ The frontend uses file-based routing with these conventions:
|
||||
- Icons use unplugin-icons with multiple icon sets (mdi, carbon, material-symbols, etc.)
|
||||
- Tailwind CSS with DaisyUI for styling
|
||||
- TypeScript definitions auto-generated in `assets/auto-imports.d.ts` and `assets/components.d.ts`
|
||||
- **Log Entry Types**: Three types of log messages supported
|
||||
- `SimpleLogEntry`: Single-line text logs (`string`)
|
||||
- `ComplexLogEntry`: Structured JSON logs (`JSONObject`)
|
||||
- `GroupedLogEntry`: Multi-line grouped logs (`string[]`)
|
||||
- **Type consistency**: Use `LogMessage` type alias instead of `string | string[] | JSONObject` for log entry messages
|
||||
- **Charts/Visualizations**: Custom lightweight implementations (no D3.js)
|
||||
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
|
||||
- Downsampling algorithm: Averages data into buckets based on available screen width
|
||||
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
|
||||
|
||||
### Backend
|
||||
|
||||
@@ -211,6 +231,14 @@ The frontend uses file-based routing with these conventions:
|
||||
- Integration tests with Playwright in `e2e/`
|
||||
- Tests must run with `TZ=UTC` for consistent timestamps
|
||||
|
||||
### Container Stats & Metrics
|
||||
|
||||
- Stats are tracked using exponential moving average (EMA) with alpha=0.2
|
||||
- History stored in rolling window (300 items max) via `useSimpleRefHistory`
|
||||
- CPU metrics normalized by core count (respects `cpuLimit` or falls back to host `nCPU`)
|
||||
- Memory metrics include both percentage and absolute usage (`memoryUsage` vs `memory`)
|
||||
- Stats visualization uses adaptive downsampling for performance
|
||||
|
||||
### Container Labels
|
||||
|
||||
- `dev.dozzle.name`: Custom container display name
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export {}
|
||||
declare global {
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
@@ -257,6 +259,7 @@ declare global {
|
||||
const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver
|
||||
const useInterval: typeof import('@vueuse/core').useInterval
|
||||
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
|
||||
const useK8sStore: typeof import('./stores/k8s').useK8sStore
|
||||
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
@@ -274,12 +277,14 @@ declare global {
|
||||
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
|
||||
const useMousePressed: typeof import('@vueuse/core').useMousePressed
|
||||
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
|
||||
const useNamespaceStream: typeof import('./composable/eventStreams').useNamespaceStream
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
|
||||
const useNetwork: typeof import('@vueuse/core').useNetwork
|
||||
const useNow: typeof import('@vueuse/core').useNow
|
||||
const useObjectUrl: typeof import('@vueuse/core').useObjectUrl
|
||||
const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination
|
||||
const useOnline: typeof import('@vueuse/core').useOnline
|
||||
const useOwnerStream: typeof import('./composable/eventStreams').useOwnerStream
|
||||
const usePageLeave: typeof import('@vueuse/core').usePageLeave
|
||||
const useParallax: typeof import('@vueuse/core').useParallax
|
||||
const useParentElement: typeof import('@vueuse/core').useParentElement
|
||||
@@ -398,6 +403,9 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
import('./stores/settings')
|
||||
}
|
||||
@@ -409,6 +417,8 @@ declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./stores/settings')['DEFAULT_SETTINGS']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly K8sNamespace: UnwrapRef<typeof import('./stores/k8s')['K8sNamespace']>
|
||||
readonly K8sOwner: UnwrapRef<typeof import('./stores/k8s')['K8sOwner']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly allLevels: UnwrapRef<typeof import('./composable/logContext')['allLevels']>
|
||||
readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']>
|
||||
@@ -656,6 +666,7 @@ declare module 'vue' {
|
||||
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
|
||||
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
|
||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||
readonly useK8sStore: UnwrapRef<typeof import('./stores/k8s')['useK8sStore']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
@@ -673,12 +684,14 @@ declare module 'vue' {
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
||||
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
||||
readonly useNamespaceStream: UnwrapRef<typeof import('./composable/eventStreams')['useNamespaceStream']>
|
||||
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
||||
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
||||
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
||||
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
|
||||
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
|
||||
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
|
||||
readonly useOwnerStream: UnwrapRef<typeof import('./composable/eventStreams')['useOwnerStream']>
|
||||
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
|
||||
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
|
||||
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
||||
|
||||
@@ -12,6 +12,7 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Announcements: typeof import('./components/Announcements.vue')['default']
|
||||
BarChart: typeof import('./components/BarChart.vue')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:information': typeof import('~icons/carbon/information')['default']
|
||||
@@ -36,6 +37,7 @@ declare module 'vue' {
|
||||
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
|
||||
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
|
||||
ContainerPopup: typeof import('./components/ContainerPopup.vue')['default']
|
||||
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
@@ -44,8 +46,10 @@ declare module 'vue' {
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
GroupedLogItem: typeof import('./components/LogViewer/GroupedLogItem.vue')['default']
|
||||
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
|
||||
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
|
||||
HostCard: typeof import('./components/HostCard.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']
|
||||
@@ -53,6 +57,7 @@ declare module 'vue' {
|
||||
'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']
|
||||
K8sMenu: typeof import('./components/K8sMenu.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']
|
||||
@@ -80,6 +85,7 @@ declare module 'vue' {
|
||||
'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:chevronDown': typeof import('~icons/mdi/chevron-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']
|
||||
@@ -94,13 +100,16 @@ declare module 'vue' {
|
||||
'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']
|
||||
MetricCard: typeof import('./components/MetricCard.vue')['default']
|
||||
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
|
||||
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
|
||||
MultiContainerLog: typeof import('./components/MultiContainerViewer/MultiContainerLog.vue')['default']
|
||||
MultiContainerStat: typeof import('./components/LogViewer/MultiContainerStat.vue')['default']
|
||||
NamespaceLog: typeof import('./components/K8sViewer/NamespaceLog.vue')['default']
|
||||
'Octicon:container24': typeof import('~icons/octicon/container24')['default']
|
||||
'Octicon:download24': typeof import('~icons/octicon/download24')['default']
|
||||
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
|
||||
OwnerLog: typeof import('./components/K8sViewer/OwnerLog.vue')['default']
|
||||
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
|
||||
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
|
||||
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
|
||||
@@ -108,13 +117,13 @@ declare module 'vue' {
|
||||
'Ph:command': typeof import('~icons/ph/command')['default']
|
||||
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
|
||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:memory': typeof import('~icons/ph/memory')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
PhArrowDown: typeof import('~icons/ph/arrow-down')['default']
|
||||
PhArrowUp: typeof import('~icons/ph/arrow-up')['default']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
|
||||
@@ -134,7 +143,6 @@ declare module 'vue' {
|
||||
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
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']
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div ref="chartContainer" class="flex items-end gap-[2px]" @mousemove="onContainerHover">
|
||||
<div
|
||||
v-for="(dataPoint, i) in downsampledData"
|
||||
:key="i"
|
||||
class="bar min-h-px flex-1 rounded-t-sm"
|
||||
:class="barClass"
|
||||
:style="{ '--height': `${Math.min(dataPoint, 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
height: var(--height);
|
||||
will-change: height;
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { chartData, barClass = "" } = defineProps<{
|
||||
chartData: number[];
|
||||
barClass?: string;
|
||||
}>();
|
||||
|
||||
const hoverIndex = defineEmit<[startIndex: number, endIndex: number]>();
|
||||
|
||||
const chartContainer = ref<HTMLElement | null>(null);
|
||||
const { width } = useElementSize(chartContainer);
|
||||
|
||||
const BAR_WIDTH = 3;
|
||||
const GAP = 2;
|
||||
|
||||
const availableBars = computed(() => Math.floor(width.value / (BAR_WIDTH + GAP)));
|
||||
const bucketSize = computed(() => Math.ceil(chartData.length / availableBars.value));
|
||||
|
||||
const downsampledData = ref<number[]>([]);
|
||||
const changeCounter = ref(-1);
|
||||
|
||||
// Watch chartData changes
|
||||
watch(
|
||||
() => chartData,
|
||||
() => {
|
||||
// If changeCounter is -1, it means this is the first time the data is loaded
|
||||
if (changeCounter.value === -1) {
|
||||
recalculate();
|
||||
}
|
||||
changeCounter.value++;
|
||||
if (changeCounter.value >= bucketSize.value) {
|
||||
// Recalculate when counter reaches bucket size
|
||||
recalculate();
|
||||
changeCounter.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recalculate when width changes
|
||||
watch([availableBars, bucketSize], () => {
|
||||
recalculate();
|
||||
changeCounter.value = -1;
|
||||
});
|
||||
|
||||
function recalculate() {
|
||||
if (chartData.length <= availableBars.value || availableBars.value === 0) {
|
||||
downsampledData.value = [...chartData];
|
||||
return;
|
||||
}
|
||||
|
||||
const size = bucketSize.value;
|
||||
const result = [];
|
||||
|
||||
// Create complete buckets
|
||||
const numCompleteBuckets = Math.floor(chartData.length / size);
|
||||
|
||||
for (let i = 0; i < numCompleteBuckets; i++) {
|
||||
const start = i * size;
|
||||
const end = start + size;
|
||||
const bucket = chartData.slice(start, end);
|
||||
const avg = bucket.reduce((sum, val) => sum + val, 0) / bucket.length;
|
||||
result.push(avg);
|
||||
}
|
||||
|
||||
// Show only the last N bars that fit on screen
|
||||
downsampledData.value = result.slice(-availableBars.value);
|
||||
}
|
||||
|
||||
function onContainerHover(event: MouseEvent) {
|
||||
if (!chartContainer.value) return;
|
||||
|
||||
const rect = chartContainer.value.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
|
||||
// Calculate which bar the mouse is over based on position
|
||||
const barWidth = width.value / downsampledData.value.length;
|
||||
const index = Math.floor(x / barWidth);
|
||||
|
||||
// Ensure index is within bounds
|
||||
if (index < 0 || index >= downsampledData.value.length) return;
|
||||
|
||||
// Map downsampled index back to original data index range
|
||||
const numCompleteBuckets = Math.floor(chartData.length / bucketSize.value);
|
||||
const offset = Math.max(0, numCompleteBuckets - availableBars.value);
|
||||
const startIndex = (offset + index) * bucketSize.value;
|
||||
const endIndex = Math.min(startIndex + bucketSize.value - 1, chartData.length - 1);
|
||||
hoverIndex(startIndex, endIndex);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
|
||||
<span class="w-fit text-right text-sm">{{ displayValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Container } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
|
||||
const { container, type, host } = defineProps<{
|
||||
container: Container;
|
||||
type: "cpu" | "mem";
|
||||
host: Host;
|
||||
}>();
|
||||
|
||||
function totalCores(): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
}
|
||||
return host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return container.statsHistory.map((stat) => Math.min(stat.cpu / cores, 100));
|
||||
}
|
||||
return container.statsHistory.map((stat) => Math.min(stat.memory, 100));
|
||||
});
|
||||
|
||||
const averageValue = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return Math.min(container.movingAverage.cpu / cores, 100);
|
||||
}
|
||||
return container.movingAverage.memory;
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (type === "cpu") {
|
||||
return `${averageValue.value.toFixed(0)}%`;
|
||||
}
|
||||
return formatBytes(container.movingAverage.memoryUsage);
|
||||
});
|
||||
|
||||
const barClass = computed(() => {
|
||||
const value = averageValue.value;
|
||||
if (value <= 50) return "bg-success";
|
||||
if (value <= 70) return "bg-secondary";
|
||||
if (value <= 90) return "bg-warning";
|
||||
return "bg-error";
|
||||
});
|
||||
</script>
|
||||
@@ -53,7 +53,7 @@
|
||||
v-for="(value, key) in fields"
|
||||
:key="key"
|
||||
@click.prevent="sort(key)"
|
||||
:class="{ 'selected-sort': key === sortField }"
|
||||
:class="[value.customClass, { 'selected-sort': key === sortField }]"
|
||||
v-show="isVisible(key)"
|
||||
>
|
||||
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
|
||||
@@ -66,8 +66,8 @@
|
||||
</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')">
|
||||
<tr v-for="container in paginated" :key="container.id" v-memo="[container.id]" class="hover:bg-base-100/80!">
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
@@ -78,26 +78,10 @@
|
||||
<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>
|
||||
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" />
|
||||
</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>
|
||||
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -131,7 +115,15 @@ import { toRefs } from "@vueuse/core";
|
||||
const { hosts } = useHosts();
|
||||
const selectedHost = ref(null);
|
||||
|
||||
const fields = {
|
||||
const fields: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
sortFunc: (a: Container, b: Container) => number;
|
||||
mobileVisible: boolean;
|
||||
customClass?: string;
|
||||
}
|
||||
> = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
@@ -141,26 +133,32 @@ const fields = {
|
||||
label: "label.host",
|
||||
sortFunc: (a: Container, b: Container) => a.hostLabel.localeCompare(b.hostLabel) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
state: {
|
||||
label: "label.status",
|
||||
sortFunc: (a: Container, b: Container) => a.state.localeCompare(b.state) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
created: {
|
||||
label: "label.created",
|
||||
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
|
||||
mobileVisible: true,
|
||||
customClass: "w-1",
|
||||
},
|
||||
cpu: {
|
||||
label: "label.avg-cpu",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
},
|
||||
mem: {
|
||||
label: "label.avg-mem",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.memory - b.movingAverage.memory) * direction.value,
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.movingAverage.memoryUsage - b.movingAverage.memoryUsage) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -208,27 +206,6 @@ 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>
|
||||
@@ -254,9 +231,6 @@ th {
|
||||
}
|
||||
|
||||
tbody td {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||
<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 tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
<div class="@container mx-2 flex items-center gap-1 md:ml-4 md:gap-2">
|
||||
<ContainerTitle :container="container" />
|
||||
<MultiContainerStat
|
||||
class="ml-auto lg:hidden lg:@3xl:flex"
|
||||
@@ -9,7 +9,7 @@
|
||||
v-if="container.state === 'running'"
|
||||
/>
|
||||
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" class="max-md:hidden" :container="container" />
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" :container="container" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
|
||||
@@ -54,7 +54,7 @@ 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");
|
||||
useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => [container.value]),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="card bg-base-100">
|
||||
<div class="card-body flex gap-2 max-md:p-4">
|
||||
<div class="flex flex-row gap-2 overflow-hidden">
|
||||
<div class="flex items-center gap-1 truncate text-xl font-semibold">
|
||||
<HostIcon :type="host.type" class="flex-none" />
|
||||
<div class="truncate">
|
||||
{{ host.name }}
|
||||
</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-2 p-2"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
>
|
||||
{{ host.agentVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="ml-auto flex flex-row flex-wrap gap-x-2 text-sm max-md:text-xs md:gap-3">
|
||||
<li class="flex items-center gap-1">
|
||||
<octicon:container-24 class="inline-block" />
|
||||
{{ $t("label.container", hostContainers.length) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 md:gap-3" v-if="stats">
|
||||
<MetricCard
|
||||
:icon="PhCpu"
|
||||
:value="stats.weighted.movingAverage.totalCPU"
|
||||
:chartData="cpuHistory"
|
||||
container-class="border-primary/40 bg-primary/20"
|
||||
text-class="text-primary"
|
||||
bar-class="bg-primary"
|
||||
:formatValue="(value) => `${value.toFixed(1)}%`"
|
||||
:label="`${host.nCPU} CPU`"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
:icon="PhMemory"
|
||||
:value="stats.weighted.movingAverage.totalMemUsage"
|
||||
:chartData="memHistory"
|
||||
container-class="border-secondary/40 bg-secondary/20"
|
||||
text-class="text-secondary"
|
||||
bar-class="bg-secondary"
|
||||
:formatValue="(value) => formatBytes(value, { decimals: 1 })"
|
||||
:label="formatBytes(host.memTotal, { decimals: 1 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Host } from "@/stores/hosts";
|
||||
import { Container } from "@/models/Container";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const props = defineProps<{
|
||||
host: Host;
|
||||
}>();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const hostContainers = computed(() =>
|
||||
containers.value.filter((container) => container.host === props.host.id && container.state === "running"),
|
||||
);
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
}
|
||||
return props.host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
totalMemUsage: number;
|
||||
};
|
||||
|
||||
const totalStat = ref<TotalStat>({ totalCPU: 0, totalMem: 0, totalMemUsage: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
|
||||
const cpuHistory = computed(() =>
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.totalCPU,
|
||||
value: stat.totalCPU,
|
||||
})),
|
||||
);
|
||||
const memHistory = computed(() =>
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.totalMem,
|
||||
value: stat.totalMemUsage,
|
||||
})),
|
||||
);
|
||||
|
||||
const stats = reactive({ mostRecent: totalStat, weighted: useExponentialMovingAverage(totalStat) });
|
||||
|
||||
watch(
|
||||
() => hostContainers.value,
|
||||
() => {
|
||||
const initial: TotalStat[] = [];
|
||||
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = hostContainers.value.reduce(
|
||||
(acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
totalCPU: acc.totalCPU + item.cpu / cores,
|
||||
totalMem: acc.totalMem + item.memory,
|
||||
totalMemUsage: acc.totalMemUsage + item.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ totalCPU: 0, totalMem: 0, totalMemUsage: 0 },
|
||||
);
|
||||
initial.push(stat);
|
||||
}
|
||||
reset({ initial: initial.reverse() });
|
||||
stats.weighted.reset(initial.at(-1)!);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
useIntervalFn(() => {
|
||||
totalStat.value = hostContainers.value.reduce(
|
||||
(acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
totalCPU: acc.totalCPU + container.stat.cpu / cores,
|
||||
totalMem: acc.totalMem + container.stat.memory,
|
||||
totalMemUsage: acc.totalMemUsage + container.stat.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ totalCPU: 0, totalMem: 0, totalMemUsage: 0 },
|
||||
);
|
||||
}, 1000);
|
||||
</script>
|
||||
@@ -1,120 +1,11 @@
|
||||
<template>
|
||||
<ul class="grid gap-4 md:grid-cols-[repeat(auto-fill,minmax(480px,1fr))]">
|
||||
<li v-for="host in hosts" class="card bg-base-100">
|
||||
<div class="card-body grid auto-cols-auto grid-flow-col justify-between gap-4">
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="flex items-center gap-1 truncate text-xl font-semibold">
|
||||
<HostIcon :type="host.type" class="flex-none" />
|
||||
<div class="truncate">
|
||||
{{ host.name }}
|
||||
</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-2 p-2"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
>
|
||||
{{ host.agentVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-row gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1"><ph:cpu /> {{ host.nCPU }} <span class="max-md:hidden">CPUs</span></li>
|
||||
<li class="flex items-center gap-1">
|
||||
<ph:memory /> {{ formatBytes(host.memTotal) }}
|
||||
<span class="max-md:hidden">total</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex flex-row flex-wrap gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1">
|
||||
<octicon:container-24 class="inline-block" />
|
||||
{{ $t("label.container", hostContainers[host.id]?.length ?? 0) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-4 md:gap-8" v-if="weightedStats[host.id]">
|
||||
<div
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalCPU / (host.nCPU * 100)) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ weightedStats[host.id].weighted.totalCPU.toFixed(0) }}%
|
||||
</div>
|
||||
<div
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalMem / host.memTotal) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ formatBytes(weightedStats[host.id].weighted.totalMem, { decimals: 1, short: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<HostCard :host="host" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((container) => container.state === "running"));
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const hostContainers = computed(() => {
|
||||
const results: Record<string, Container[]> = {};
|
||||
for (const container of runningContainers.value) {
|
||||
if (!results[container.host]) {
|
||||
results[container.host] = [];
|
||||
}
|
||||
results[container.host].push(container);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
};
|
||||
const weightedStats: Record<string, { mostRecent: TotalStat; weighted: TotalStat }> = {};
|
||||
const initWeightedStats = () => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const mostRecent = ref<TotalStat>({ totalCPU: 0, totalMem: 0 });
|
||||
for (const container of containers) {
|
||||
mostRecent.value.totalCPU += container.stat.cpu;
|
||||
mostRecent.value.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
weightedStats[host] = reactive({ mostRecent, weighted: useExponentialMovingAverage(mostRecent) });
|
||||
}
|
||||
};
|
||||
|
||||
watchOnce(hostContainers, initWeightedStats);
|
||||
initWeightedStats();
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const stat = { totalCPU: 0, totalMem: 0 };
|
||||
for (const container of containers) {
|
||||
stat.totalCPU += container.stat.cpu;
|
||||
stat.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
if (weightedStats[host]) {
|
||||
// TODO fix this init
|
||||
weightedStats[host].mostRecent = stat;
|
||||
}
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-hosts')"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ hosts[sessionHost].name }}
|
||||
@@ -64,7 +64,7 @@
|
||||
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<component :is="icon" />
|
||||
{{ label.startsWith("label.") ? $t(label) : label }}
|
||||
{{ label.startsWith("label.") ? $t(label) : label }} ({{ containers.length }})
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
@@ -73,7 +73,7 @@
|
||||
}"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-containers')"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div class="breadcrumbs flex-1">
|
||||
<ul>
|
||||
<li>
|
||||
<a @click.prevent="setNamespace(null)" class="link-primary">{{ $t("label.namespaces") }}</a>
|
||||
</li>
|
||||
<li v-if="selectedNamespace === 'all'">
|
||||
{{ $t("label.all-namespaces") }}
|
||||
</li>
|
||||
<li v-else-if="selectedNamespace" class="cursor-default">
|
||||
<router-link
|
||||
:to="{
|
||||
name: '/namespace/[name]',
|
||||
params: { name: selectedNamespace },
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ selectedNamespace }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<SlideTransition :slide-right="selectedNamespace !== null">
|
||||
<template #left>
|
||||
<ul class="menu p-0">
|
||||
<li>
|
||||
<a @click.prevent="setNamespace('all')">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.all-namespaces") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-for="ns in namespaces" :key="ns.name">
|
||||
<a @click.prevent="setNamespace(ns.name)">
|
||||
<ph:circles-four />
|
||||
{{ ns.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
<ul class="menu w-full p-0 text-[0.95rem]" ref="menu">
|
||||
<li v-for="{ name, owners } in filteredNamespaces" :key="name">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:stack />
|
||||
{{ name }} ({{ owners.length }})
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/namespace/[name]', params: { name } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li v-if="ownersWithoutNamespace.length > 0">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</SlideTransition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const store = useK8sStore();
|
||||
|
||||
const { namespaces, owners } = storeToRefs(store);
|
||||
|
||||
const selectedNamespace = ref<string | null>("all");
|
||||
|
||||
const setNamespace = (namespace: string | null) => (selectedNamespace.value = namespace);
|
||||
|
||||
const filteredNamespaces = computed(() => {
|
||||
if (selectedNamespace.value === null || selectedNamespace.value === "all") {
|
||||
return namespaces.value;
|
||||
}
|
||||
return namespaces.value.filter((ns) => ns.name === selectedNamespace.value);
|
||||
});
|
||||
|
||||
const ownersWithoutNamespace = computed(() => {
|
||||
const filtered = owners.value.filter((owner) => !owner.namespace);
|
||||
if (selectedNamespace.value === null || selectedNamespace.value === "all") {
|
||||
return filtered;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const menu = useTemplateRef("menu");
|
||||
|
||||
const collapseAll = () => {
|
||||
const details = menu.value?.querySelectorAll("details");
|
||||
details?.forEach((detail) => (detail.open = false));
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="namespace.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">
|
||||
<ph:stack />
|
||||
<div class="font-mono text-sm font-semibold">{{ namespace.name }}</div>
|
||||
<ContainerDropdown :containers="namespace.containers">
|
||||
{{ $t("label.container", namespace.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="namespace.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useNamespaceStream"
|
||||
:entity="namespace"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { K8sNamespace } from "@/stores/k8s";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { namespace, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
namespace: K8sNamespace;
|
||||
}>();
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => namespace.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="owner.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">
|
||||
<ph:stack-simple />
|
||||
<div class="font-mono text-sm font-semibold">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
<ContainerDropdown :containers="owner.containers">
|
||||
{{ $t("label.container", owner.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="owner.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useOwnerStream"
|
||||
:entity="owner"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { K8sOwner } from "@/stores/k8s";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { owner, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
owner: K8sOwner;
|
||||
}>();
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => owner.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
@@ -23,6 +23,7 @@
|
||||
</ul>
|
||||
</DefineTemplate>
|
||||
<LogItem :logEntry>
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" />
|
||||
<div @click="containers.length > 0 && showDrawer(LogDetails, { entry: logEntry })" class="cursor-pointer">
|
||||
<ReuseTemplate :data="validValues" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<LogItem :logEntry>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="(msg, index) in logEntry.message" :key="index" class="flex items-start gap-x-2">
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" :position="getPosition(index)" />
|
||||
<div
|
||||
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre"
|
||||
v-html="colorize(msg)"
|
||||
:class="{ 'min-h-4': msg === '' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: GroupedLogEntry;
|
||||
}>();
|
||||
|
||||
const getPosition = (index: number): Position => {
|
||||
const len = logEntry.message.length;
|
||||
if (index === 0) return "start";
|
||||
if (index === len - 1) return "end";
|
||||
return "middle";
|
||||
};
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
@@ -44,18 +44,29 @@
|
||||
{{ $t("action.see-in-context") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="isSupported">
|
||||
<a @click="copyLogMessage()">
|
||||
<li>
|
||||
<a
|
||||
@click="copyLogMessage()"
|
||||
:disabled="!isSupported"
|
||||
:title="!isSupported ? $t('error.copy-not-supported') : ''"
|
||||
:class="{ 'cursor-not-allowed opacity-50': !isSupported }"
|
||||
>
|
||||
<material-symbols:content-copy />
|
||||
{{ $t("action.copy-log") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="isSupported">
|
||||
<a @click="copyPermalink()">
|
||||
<li>
|
||||
<a
|
||||
@click="copyPermalink()"
|
||||
:disabled="!isSupported"
|
||||
:title="!isSupported ? $t('error.copy-not-supported') : ''"
|
||||
:class="{ 'cursor-not-allowed opacity-50': !isSupported }"
|
||||
>
|
||||
<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 />
|
||||
@@ -69,7 +80,7 @@
|
||||
<script lang="ts" setup>
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { Container } from "@/models/Container";
|
||||
import { LogEntry, SimpleLogEntry, ComplexLogEntry, JSONObject } from "@/models/LogEntry";
|
||||
import { LogEntry, SimpleLogEntry, ComplexLogEntry, GroupedLogEntry, JSONObject } from "@/models/LogEntry";
|
||||
import LogDetails from "./LogDetails.vue";
|
||||
|
||||
const { logEntry, container } = defineProps<{
|
||||
@@ -86,10 +97,16 @@ const { copy, isSupported, copied } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessage() {
|
||||
if (!isSupported.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logEntry instanceof ComplexLogEntry) {
|
||||
await copy(stripAnsi(logEntry.rawMessage));
|
||||
} else if (logEntry instanceof SimpleLogEntry) {
|
||||
await copy(stripAnsi(logEntry.rawMessage));
|
||||
} else if (logEntry instanceof GroupedLogEntry) {
|
||||
await copy(stripAnsi(logEntry.message.join("\n")));
|
||||
}
|
||||
|
||||
if (copied.value) {
|
||||
@@ -105,6 +122,9 @@ async function copyLogMessage() {
|
||||
}
|
||||
|
||||
async function copyPermalink() {
|
||||
if (!isSupported.value) {
|
||||
return;
|
||||
}
|
||||
const url = router.resolve({
|
||||
name: "/container/[id].time.[datetime]",
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
|
||||
@@ -19,16 +19,11 @@
|
||||
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
|
||||
/>
|
||||
</div>
|
||||
<LogLevel
|
||||
class="flex select-none"
|
||||
:level="logEntry.level"
|
||||
:position="logEntry instanceof SimpleLogEntry ? logEntry.position : undefined"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry, SimpleLogEntry } from "@/models/LogEntry";
|
||||
import { LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: LogEntry<any>;
|
||||
|
||||
@@ -22,26 +22,25 @@ const {
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
[data-position="start"],
|
||||
[data-position="middle"],
|
||||
[data-position="end"] {
|
||||
align-self: stretch;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[data-position="start"] {
|
||||
border-radius: 0.5em 0.5em 0 0;
|
||||
height: 70%;
|
||||
margin-bottom: -0.4em;
|
||||
margin-top: auto;
|
||||
align-self: flex-end;
|
||||
border-radius: 0.375rem 0.375rem 0 0;
|
||||
}
|
||||
|
||||
[data-position="middle"] {
|
||||
border-radius: 0;
|
||||
height: auto;
|
||||
margin: -0.4em 0;
|
||||
align-self: stretch;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
[data-position="end"] {
|
||||
border-radius: 0 0 0.5em 0.5em;
|
||||
height: 70%;
|
||||
margin-top: -0.4em;
|
||||
align-self: flex-start;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
const { progress, currentDate } = useScrollContext();
|
||||
|
||||
const { messages } = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
messages: LogEntry<string | string[] | JSONObject>[];
|
||||
}>();
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
messages: LogEntry<string | string[] | JSONObject>[];
|
||||
visibleKeys: Map<string[], boolean>;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||
<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 tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
<template>
|
||||
<div class="flex gap-4">
|
||||
<StatMonitor
|
||||
:data="memoryData"
|
||||
label="mem"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
|
||||
/>
|
||||
<div class="flex gap-1 md:gap-4">
|
||||
<div class="grid min-w-15 grid-cols-[auto_1fr] items-center gap-0.5 text-xs leading-none max-md:hidden">
|
||||
<PhArrowUp class="text-primary" />
|
||||
<span class="tabular-nums">{{ formatBytes(networkRate.tx, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary" />
|
||||
<span class="tabular-nums">{{ formatBytes(networkRate.rx, { short: true, decimals: 1 }) }}/s</span>
|
||||
</div>
|
||||
<StatMonitor
|
||||
:data="cpuData"
|
||||
label="load"
|
||||
:icon="PhCpu"
|
||||
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
|
||||
:limit="roundCPU(limits.cpu) + ' CPU'"
|
||||
container-class="border-primary/40 bg-primary/20"
|
||||
text-class="hover:text-primary"
|
||||
bar-class="bg-primary"
|
||||
:formatter="(value: number) => value.toFixed(2) + '%'"
|
||||
/>
|
||||
<StatMonitor
|
||||
:data="memoryData"
|
||||
:icon="PhMemory"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
|
||||
container-class="border-secondary/40 bg-secondary/20"
|
||||
text-class="hover:text-secondary"
|
||||
bar-class="bg-secondary"
|
||||
:formatter="(value: number) => formatBytes(value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,106 +32,145 @@
|
||||
<script lang="ts" setup>
|
||||
import { Stat } from "@/models/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
const networkRate = ref({ rx: 0, tx: 0 });
|
||||
|
||||
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
}
|
||||
const hostInfo = hosts.value[container.host];
|
||||
return hostInfo?.nCPU ?? 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => containers,
|
||||
() => {
|
||||
const initial: Stat[] = [];
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = containers.reduce(
|
||||
(acc, { statsHistory }) => {
|
||||
const item = statsHistory.at(-i);
|
||||
(acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu,
|
||||
cpu: acc.cpu + item.cpu / cores,
|
||||
memory: acc.memory + item.memory,
|
||||
memoryUsage: acc.memoryUsage + item.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + item.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + item.networkTxTotal,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
|
||||
);
|
||||
initial.push(stat);
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const limits = computed(() => {
|
||||
const hostLimits = new Map<string, { cpu: number; memory: number }>();
|
||||
// Group containers by host
|
||||
const containersByHost = new Map<string, Container[]>();
|
||||
containers.forEach((container) => {
|
||||
if (!containersByHost.has(container.host)) {
|
||||
containersByHost.set(container.host, []);
|
||||
}
|
||||
containersByHost.get(container.host)!.push(container);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
let totalCpu = 0;
|
||||
let totalMemory = 0;
|
||||
|
||||
return hostLimits.values().reduce(
|
||||
(acc, { cpu, memory }) => {
|
||||
return {
|
||||
cpu: acc.cpu + cpu,
|
||||
memory: acc.memory + memory,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0 },
|
||||
);
|
||||
// Process each host independently
|
||||
containersByHost.forEach((hostContainers, hostId) => {
|
||||
const hostInfo = hosts.value[hostId];
|
||||
const hostTotalMemory = hostInfo?.memTotal || 0;
|
||||
const hostTotalCpu = hostInfo?.nCPU || 0;
|
||||
|
||||
// Check if any container lacks limits
|
||||
const hasUnlimitedCpu = hostContainers.some((c) => !c.cpuLimit || c.cpuLimit <= 0);
|
||||
const hasUnlimitedMemory = hostContainers.some((c) => !c.memoryLimit);
|
||||
|
||||
// Calculate CPU for this host
|
||||
if (hasUnlimitedCpu) {
|
||||
// At least one container has no limit, use host total
|
||||
totalCpu += hostTotalCpu;
|
||||
} else {
|
||||
// All containers have limits, sum them up (capped at host total)
|
||||
const sumCpu = hostContainers.reduce((sum, c) => sum + 1, 0);
|
||||
totalCpu += Math.min(sumCpu, hostTotalCpu);
|
||||
}
|
||||
|
||||
// Calculate Memory for this host
|
||||
if (hasUnlimitedMemory) {
|
||||
// At least one container has no limit, use host total
|
||||
totalMemory += hostTotalMemory;
|
||||
} else {
|
||||
// All containers have limits, sum them up (capped at host total)
|
||||
const sumMemory = hostContainers.reduce((sum, c) => sum + (c.memoryLimit || 0), 0);
|
||||
totalMemory += Math.min(sumMemory, hostTotalMemory);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
cpu: totalCpu,
|
||||
memory: totalMemory,
|
||||
};
|
||||
});
|
||||
|
||||
useIntervalFn(() => {
|
||||
const previousStat = totalStat.value;
|
||||
totalStat.value = containers.reduce(
|
||||
(acc, { stat }) => {
|
||||
(acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + stat.cpu,
|
||||
memory: acc.memory + stat.memory,
|
||||
memoryUsage: acc.memoryUsage + stat.memoryUsage,
|
||||
cpu: acc.cpu + container.stat.cpu / cores,
|
||||
memory: acc.memory + container.stat.memory,
|
||||
memoryUsage: acc.memoryUsage + container.stat.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + container.stat.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + container.stat.networkTxTotal,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
|
||||
);
|
||||
|
||||
networkRate.value = {
|
||||
rx: Math.max(0, totalStat.value.networkRxTotal - previousStat.networkRxTotal),
|
||||
tx: Math.max(0, totalStat.value.networkTxTotal - previousStat.networkTxTotal),
|
||||
};
|
||||
}, 1000);
|
||||
|
||||
const cpuData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu).toFixed(2) + "%",
|
||||
value: Math.max(0, stat.cpu),
|
||||
})),
|
||||
);
|
||||
|
||||
const memoryData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.memoryUsage,
|
||||
value: formatBytes(stat.memoryUsage),
|
||||
y: stat.memory,
|
||||
value: stat.memoryUsage,
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<LogItem :logEntry>
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" />
|
||||
<div
|
||||
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre"
|
||||
v-html="colorize(logEntry.message)"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<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">
|
||||
<StatSparkline :data="data" @selected-point="onSelectedPoint" />
|
||||
<div class="relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false" :class="textClass">
|
||||
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
|
||||
<BarChart
|
||||
:chart-data="chartData"
|
||||
:bar-class="`${barClass} opacity-70 hover:opacity-100`"
|
||||
class="h-8 w-44"
|
||||
@hover-index="(startIndex: number, endIndex: number) => onHoverIndexChange(startIndex, endIndex)"
|
||||
/>
|
||||
</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 }}
|
||||
<div class="bg-base-200 flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
|
||||
<component :is="icon" class="text-sm" />
|
||||
<div class="font-bold tabular-nums select-none">
|
||||
{{ displayValue }}
|
||||
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,18 +19,48 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
|
||||
const {
|
||||
data,
|
||||
label,
|
||||
icon,
|
||||
statValue,
|
||||
limit = -1,
|
||||
containerClass = "border-primary",
|
||||
textClass = "",
|
||||
barClass = "bg-primary",
|
||||
formatter,
|
||||
} = defineProps<{
|
||||
data: Point<unknown>[];
|
||||
label: string;
|
||||
icon: Component;
|
||||
statValue: string | number;
|
||||
limit?: string | number;
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatter?: (value: number) => string;
|
||||
}>();
|
||||
const selectedPoint = ref<Point<unknown> | undefined>();
|
||||
const onSelectedPoint = (point: Point<unknown>) => (selectedPoint.value = point);
|
||||
|
||||
const chartData = computed(() => data.map((point) => (point.y as number) ?? 0));
|
||||
const mouseOver = ref(false);
|
||||
const hoveredRange = ref<{ start: number; end: number } | null>(null);
|
||||
|
||||
function onHoverIndexChange(startIndex: number, endIndex: number) {
|
||||
hoveredRange.value = { start: startIndex, end: endIndex };
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (mouseOver.value && hoveredRange.value !== null) {
|
||||
const { start, end } = hoveredRange.value;
|
||||
const points = data.slice(start, end + 1);
|
||||
const sum = points.reduce((acc, point) => acc + ((point.value as number) ?? (point.y as number) ?? 0), 0);
|
||||
const avg = sum / points.length;
|
||||
|
||||
if (formatter) {
|
||||
return formatter(avg);
|
||||
}
|
||||
return avg.toFixed(2);
|
||||
}
|
||||
return statValue;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<svg :width="width" :height="height" @mousemove="onMove" class="group">
|
||||
<path :d="path" class="fill-primary" />
|
||||
<line :x1="lineX" y1="0" :x2="lineX" :y2="height" class="stroke-secondary invisible stroke-2 group-hover:visible" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { extent } from "d3-array";
|
||||
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 x = d3.scaleLinear().range([0, width]);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const selectedPoint = defineEmit<[value: Point<unknown>]>();
|
||||
|
||||
const shape = d3
|
||||
.area<Point<unknown>>()
|
||||
.curve(d3.curveStep)
|
||||
.x((d) => x(d.x))
|
||||
.y0(height)
|
||||
.y1((d) => y(d.y));
|
||||
|
||||
const path = computed(() => {
|
||||
x.domain(d3.extent(data, (d) => d.x) as [number, number]);
|
||||
y.domain(d3.extent([...data, { y: 1 }], (d) => d.y) as [number, number]);
|
||||
|
||||
return shape(data) ?? "";
|
||||
});
|
||||
|
||||
let lineX = $ref(0);
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
const { offsetX } = e;
|
||||
const xValue = x.invert(offsetX);
|
||||
const index = Math.round(xValue);
|
||||
lineX = x(index);
|
||||
const point = data[index];
|
||||
selectedPoint(point);
|
||||
}
|
||||
</script>
|
||||
@@ -14,8 +14,12 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</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-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
</ul>
|
||||
</div>
|
||||
@@ -48,8 +52,12 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</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-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
</ul>
|
||||
</div>
|
||||
@@ -82,8 +90,12 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
||||
</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-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="rounded-lg border p-2 md:p-3" :class="containerClass">
|
||||
<div class="mb-2 flex items-center gap-1.5 text-sm font-medium" :class="textClass">
|
||||
<component :is="icon" class="text-lg" />
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="mb-1.5 text-lg font-semibold tabular-nums">{{ formattedValue }}</div>
|
||||
<div class="text-base-content/60 mb-1 text-xs tabular-nums max-md:hidden">
|
||||
avg {{ formatValue(average) }} • pk {{ formatValue(peak) }}
|
||||
</div>
|
||||
<BarChart class="h-8" :chartData="percentData" :barClass="barClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
export interface MetricDataPoint {
|
||||
percent: number; // value 0 - 100
|
||||
value: number;
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
chartData,
|
||||
containerClass = "",
|
||||
textClass = "",
|
||||
barClass = "",
|
||||
formatValue = (v: number) => v.toString(),
|
||||
} = defineProps<{
|
||||
label: string;
|
||||
icon: Component;
|
||||
value: string | number;
|
||||
chartData: MetricDataPoint[];
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const percentData = computed(() => chartData.map((d) => d.percent));
|
||||
|
||||
const peak = computed(() => (chartData.length > 0 ? Math.max(...chartData.map((d) => d.value)) : 0));
|
||||
|
||||
const average = computed(() => {
|
||||
if (chartData.length === 0) return 0;
|
||||
return chartData.reduce((sum, d) => sum + d.value, 0) / chartData.length;
|
||||
});
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (typeof value === "string") return value;
|
||||
return formatValue(value);
|
||||
});
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="containers.length && ready">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="mx-2 flex items-center gap-1 md:ml-4 md:gap-2">
|
||||
<octicon:container-24 />
|
||||
<ContainerDropdown :containers="containers">{{ $t("label.container", containers.length) }}</ContainerDropdown>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -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-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<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 w-full flex-col">
|
||||
<Carousel v-model="selectedCard" class="flex-1">
|
||||
<CarouselItem v-if="config.mode === 'k8s'" :title="$t('label.k8s-menu')" id="k8s">
|
||||
<K8sMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem v-if="config.mode === 'swarm' && services.length > 0" :title="$t('label.swarm-menu')" id="swarm">
|
||||
<SwarmMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.host-menu')" id="host">
|
||||
<HostMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.group-menu')" v-if="customGroups.length > 0" id="group">
|
||||
<GroupMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.swarm-menu')" v-if="services.length > 0" id="swarm">
|
||||
<CarouselItem v-if="config.mode !== 'swarm' && services.length > 0" :title="$t('label.swarm-menu')" id="swarm">
|
||||
<SwarmMenu />
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
@@ -24,13 +30,25 @@ const { ready } = storeToRefs(containerStore);
|
||||
const route = useRoute();
|
||||
const swarmStore = useSwarmStore();
|
||||
const { services, customGroups } = storeToRefs(swarmStore);
|
||||
const selectedCard = ref<"host" | "swarm" | "group">("host");
|
||||
|
||||
let defaultCard: "host" | "swarm" | "group" | "k8s";
|
||||
switch (config.mode) {
|
||||
case "k8s":
|
||||
defaultCard = "k8s";
|
||||
break;
|
||||
case "swarm":
|
||||
defaultCard = "swarm";
|
||||
break;
|
||||
default:
|
||||
defaultCard = "host";
|
||||
}
|
||||
const selectedCard = ref<"host" | "swarm" | "group" | "k8s">(defaultCard);
|
||||
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
if (route.meta.menu && ["host", "swarm", "group"].includes(route.meta.menu as string)) {
|
||||
selectedCard.value = route.meta.menu as "host" | "swarm" | "group";
|
||||
if (route.meta.menu && ["host", "swarm", "group", "k8s"].includes(route.meta.menu as string)) {
|
||||
selectedCard.value = route.meta.menu as "host" | "swarm" | "group" | "k8s";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:stack />
|
||||
{{ name }}
|
||||
{{ name }} ({{ services.length }})
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/stack/[name]', params: { name } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-services')"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
@@ -55,7 +55,7 @@
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.services") }}
|
||||
{{ $t("label.services") }} ({{ servicesWithoutStacks.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="service in servicesWithoutStacks" :key="service.name">
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<template>
|
||||
<aside>
|
||||
<aside class="flex h-[calc(100svh-50px)] flex-col gap-2">
|
||||
<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>
|
||||
<div ref="host" class="shell flex-1"></div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -21,34 +17,72 @@ const { container, action } = defineProps<{ container: Container; action: "attac
|
||||
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
const { WebLinksAddon } = await import("@xterm/addon-web-links");
|
||||
const { FitAddon } = await import("@xterm/addon-fit");
|
||||
|
||||
const host = useTemplateRef<HTMLDivElement>("host");
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
theme: {
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
});
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
function sendEvent(type: "userinput" | "resize", data?: string, width?: number, height?: number) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const event: { type: string; data?: string; width?: number; height?: number } = { type };
|
||||
if (data !== undefined) event.data = data;
|
||||
if (width !== undefined) event.width = width;
|
||||
if (height !== undefined) event.height = height;
|
||||
|
||||
ws.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
terminal.open(host.value!);
|
||||
terminal.resize(100, 40);
|
||||
fitAddon.fit();
|
||||
|
||||
ws = new WebSocket(withBase(`/api/hosts/${container.host}/containers/${container.id}/${action}`));
|
||||
ws.onopen = () => {
|
||||
terminal.writeln(`Attaching to ${container.name} 🚀`);
|
||||
|
||||
// Send initial resize event
|
||||
sendEvent("resize", undefined, terminal.cols, terminal.rows);
|
||||
|
||||
if (action === "attach") {
|
||||
ws?.send("\r");
|
||||
sendEvent("userinput", "\r");
|
||||
}
|
||||
|
||||
terminal.onData((data) => {
|
||||
ws?.send(data);
|
||||
sendEvent("userinput", data);
|
||||
});
|
||||
|
||||
// Handle terminal resize
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
sendEvent("resize", undefined, cols, rows);
|
||||
});
|
||||
|
||||
terminal.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => terminal.write(event.data);
|
||||
ws.addEventListener("close", () => {
|
||||
terminal.writeln("⚠️ Connection closed");
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
const { width, height } = useWindowSize();
|
||||
watch([width, height], () => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -62,7 +96,7 @@ onUnmounted(() => {
|
||||
|
||||
.shell {
|
||||
& :deep(.terminal) {
|
||||
@apply overflow-hidden rounded border-1 p-2;
|
||||
@apply overflow-hidden rounded border p-2;
|
||||
&:is(.focus) {
|
||||
@apply border-primary;
|
||||
}
|
||||
@@ -79,6 +113,10 @@ onUnmounted(() => {
|
||||
& :deep(.xterm-cursor-block.xterm-cursor-blink) {
|
||||
animation-name: blink !important;
|
||||
}
|
||||
|
||||
& :deep(.xterm-selection) {
|
||||
@apply bg-primary/30;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
@@ -4,6 +4,7 @@ import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
type JSONObject,
|
||||
type LogMessage,
|
||||
LogEntry,
|
||||
asLogEntry,
|
||||
ContainerEventLogEntry,
|
||||
@@ -16,7 +17,7 @@ import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
function parseMessage(data: string): LogEntry<LogMessage> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
@@ -31,7 +32,8 @@ export function useHostStream(host: Ref<Host>): LogStreamSource {
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/stacks/${stack.value.name}/logs/stream`));
|
||||
const labels = computed(() => `com.docker.stack.namespace:${stack.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource {
|
||||
@@ -48,14 +50,25 @@ export function useMergedStream(containers: Ref<Container[]>): LogStreamSource {
|
||||
}
|
||||
|
||||
export function useServiceStream(service: Ref<Service>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/services/${service.value.name}/logs/stream`));
|
||||
const labels = computed(() => `com.docker.swarm.service.name:${service.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
|
||||
const labels = computed(() => `namespace:${namespace.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
|
||||
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export type LogStreamSource = ReturnType<typeof useLogStream>;
|
||||
|
||||
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
|
||||
const buffer: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
|
||||
const messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
const buffer: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HistoricalContainer } from "@/models/Container";
|
||||
import { JSONObject, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
|
||||
import { LogMessage, 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 messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
|
||||
import { ComplexLogEntry, type LogMessage, type LogEntry } from "@/models/LogEntry";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
|
||||
const { isSearching } = useSearchFilter();
|
||||
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
function filteredPayload(messages: Ref<LogEntry<LogMessage>[]>) {
|
||||
return computed(() => {
|
||||
return messages.value
|
||||
.map((d) => {
|
||||
|
||||
@@ -147,3 +147,7 @@ body {
|
||||
[class*="shadow-"] {
|
||||
@apply shadow-base-content/8;
|
||||
}
|
||||
|
||||
.splitpanes--vertical .splitpanes__pane {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -51,10 +51,13 @@ export class Container {
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
) {
|
||||
this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat));
|
||||
this._stat = ref(
|
||||
stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat),
|
||||
);
|
||||
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
|
||||
this._statsHistory = history;
|
||||
this.movingAverageStat = useExponentialMovingAverage(this._stat, 0.2);
|
||||
const { movingAverage } = useExponentialMovingAverage(this._stat, 0.2);
|
||||
this.movingAverageStat = movingAverage;
|
||||
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Component, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON } from "@/utils";
|
||||
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
|
||||
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
|
||||
import GroupedLogItem from "@/components/LogViewer/GroupedLogItem.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 };
|
||||
export type Position = "start" | "end" | "middle" | undefined;
|
||||
export type Std = "stdout" | "stderr";
|
||||
export type LogType = "single" | "group" | "complex";
|
||||
export type Position = "start" | "end" | "middle" | undefined;
|
||||
export type LogMessage = string | string[] | JSONObject;
|
||||
export type Level =
|
||||
| "error"
|
||||
| "warn"
|
||||
@@ -21,18 +24,23 @@ export type Level =
|
||||
| "critical"
|
||||
| "fatal"
|
||||
| "unknown";
|
||||
|
||||
export interface LogFragment {
|
||||
readonly m: string;
|
||||
}
|
||||
|
||||
export interface LogEvent {
|
||||
readonly m: string | JSONObject;
|
||||
readonly t: LogType;
|
||||
readonly m: string | LogFragment[] | JSONObject;
|
||||
readonly ts: number;
|
||||
readonly id: number;
|
||||
readonly l: Level;
|
||||
readonly p: Position;
|
||||
readonly s: "stdout" | "stderr" | "unknown";
|
||||
readonly c: string;
|
||||
readonly rm: string;
|
||||
}
|
||||
|
||||
export abstract class LogEntry<T extends string | JSONObject> {
|
||||
export abstract class LogEntry<T extends LogMessage> {
|
||||
protected readonly _message: T;
|
||||
constructor(
|
||||
message: T,
|
||||
@@ -60,7 +68,6 @@ export class SimpleLogEntry extends LogEntry<string> {
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly position: Position,
|
||||
public readonly std: Std,
|
||||
public readonly rawMessage: string,
|
||||
) {
|
||||
@@ -71,6 +78,27 @@ export class SimpleLogEntry extends LogEntry<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupedLogEntry extends LogEntry<string[]> {
|
||||
constructor(
|
||||
messages: string[],
|
||||
containerID: string,
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly std: Std,
|
||||
) {
|
||||
super(messages as any, containerID, id, date, std, "", level);
|
||||
}
|
||||
|
||||
public get message(): string[] {
|
||||
return this._message as unknown as string[];
|
||||
}
|
||||
|
||||
getComponent(): Component {
|
||||
return GroupedLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
private readonly filteredMessage: ComputedRef<Record<string, any>>;
|
||||
|
||||
@@ -207,27 +235,23 @@ export class LoadMoreLogEntry extends LogEntry<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
|
||||
if (isObject(event.m)) {
|
||||
return new ComplexLogEntry(
|
||||
event.m,
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
|
||||
event.rm,
|
||||
);
|
||||
} else {
|
||||
return new SimpleLogEntry(
|
||||
event.m,
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
event.p,
|
||||
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
|
||||
event.rm,
|
||||
);
|
||||
export function asLogEntry(event: LogEvent): LogEntry<LogMessage> {
|
||||
const std = event.s === "unknown" ? "stderr" : (event.s ?? "stderr");
|
||||
|
||||
switch (event.t) {
|
||||
case "complex":
|
||||
return new ComplexLogEntry(event.m as JSONObject, event.c, event.id, new Date(event.ts), event.l, std, event.rm);
|
||||
case "group":
|
||||
return new GroupedLogEntry(
|
||||
(event.m as LogFragment[]).map((f) => f.m),
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
std,
|
||||
);
|
||||
case "single":
|
||||
default:
|
||||
return new SimpleLogEntry(event.m as string, event.c, event.id, new Date(event.ts), event.l, std, event.rm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
<template>
|
||||
<PageWithLinks>
|
||||
<section>
|
||||
<HostList />
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">{{ $t("label.host-count", { count: Object.keys(hosts).length }) }}</h2>
|
||||
<button @click="hostsCollapsed = !hostsCollapsed" class="btn btn-ghost btn-sm">
|
||||
<mdi:chevron-down :class="{ 'rotate-180': !hostsCollapsed }" class="transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="collapse">
|
||||
<HostList v-show="!hostsCollapsed" />
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<ContainerTable :containers="runningContainers"></ContainerTable>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ $t("label.container", { count: runningContainers.length }) }}
|
||||
</h2>
|
||||
<button @click="containersCollapsed = !containersCollapsed" class="btn btn-ghost btn-sm">
|
||||
<mdi:chevron-down :class="{ 'rotate-180': !containersCollapsed }" class="transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="collapse">
|
||||
<ContainerTable v-show="!containersCollapsed" :containers="runningContainers" />
|
||||
</Transition>
|
||||
</section>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
@@ -14,6 +32,7 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
@@ -23,6 +42,10 @@ const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
// Persist collapse state in localStorage
|
||||
const hostsCollapsed = useStorage("DOZZLE_HOSTS_COLLAPSED", false);
|
||||
const containersCollapsed = useStorage("DOZZLE_CONTAINERS_COLLAPSED", false);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
setTitle(t("title.dashboard", { count: runningContainers.value.length }));
|
||||
@@ -34,4 +57,16 @@ watchEffect(() => {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.collapse-enter-active,
|
||||
.collapse-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapse-enter-from,
|
||||
.collapse-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<Search />
|
||||
<NamespaceLog :namespace="namespace" :scrollable="pinnedLogs.length > 0" v-if="namespace" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/namespace/[name]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { namespaces } = storeToRefs(k8sStore);
|
||||
const namespace = computed(() => namespaces.value.find((ns) => ns.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (namespace.value?.name) {
|
||||
setTitle(namespace.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: k8s
|
||||
</route>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<Search />
|
||||
<OwnerLog :owner="owner" :scrollable="pinnedLogs.length > 0" v-if="owner" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/owner/[name]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { owners } = storeToRefs(k8sStore);
|
||||
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (owner.value?.name) {
|
||||
setTitle(`${owner.value.kind}/${owner.value.name}`);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: k8s
|
||||
</route>
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
<Toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </Toggle>
|
||||
|
||||
<Toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</Toggle>
|
||||
<Toggle v-model="showTimestamp">{{ $t("settings.show-timestamps") }}</Toggle>
|
||||
|
||||
<Toggle v-model="showStd">{{ $t("settings.show-std") }}</Toggle>
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ComplexLogEntry, SimpleLogEntry } from "@/models/LogEntry";
|
||||
import { ComplexLogEntry, SimpleLogEntry, GroupedLogEntry } from "@/models/LogEntry";
|
||||
|
||||
import {
|
||||
automaticRedirect,
|
||||
@@ -220,29 +220,20 @@ 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.multi-line-error.start-line"),
|
||||
new SimpleLogEntry(t("settings.log.preview"), "123", 1, hoursAgo(16), "info", "stdout", ""),
|
||||
new SimpleLogEntry(t("settings.log.warning"), "123", 2, hoursAgo(12), "warn", "stdout", ""),
|
||||
new GroupedLogEntry(
|
||||
[
|
||||
t("settings.log.multi-line-error.start-line"),
|
||||
t("settings.log.multi-line-error.middle-line"),
|
||||
t("settings.log.multi-line-error.end-line"),
|
||||
],
|
||||
"123",
|
||||
3,
|
||||
hoursAgo(7),
|
||||
"error",
|
||||
"start",
|
||||
"stderr",
|
||||
"",
|
||||
),
|
||||
new SimpleLogEntry(
|
||||
t("settings.log.multi-line-error.middle-line"),
|
||||
"123",
|
||||
4,
|
||||
hoursAgo(2),
|
||||
"error",
|
||||
"middle",
|
||||
"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"),
|
||||
@@ -258,7 +249,7 @@ const fakeMessages = computedWithControl(
|
||||
"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", "stderr", ""),
|
||||
],
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Config {
|
||||
base: string;
|
||||
maxLogs: number;
|
||||
hostname: string;
|
||||
mode: "server" | "swarm" | "k8s";
|
||||
hosts: Host[];
|
||||
authProvider: "simple" | "none" | "forward-proxy";
|
||||
logoutUrl?: string;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
export class K8sNamespace {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly containers: Container[],
|
||||
public readonly owners: K8sOwner[],
|
||||
) {
|
||||
for (const owner of owners) {
|
||||
owner.namespace = this;
|
||||
}
|
||||
}
|
||||
|
||||
get updatedAt() {
|
||||
return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0));
|
||||
}
|
||||
}
|
||||
|
||||
export class K8sOwner {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly kind: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
|
||||
namespace?: K8sNamespace;
|
||||
|
||||
get updatedAt() {
|
||||
return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0));
|
||||
}
|
||||
}
|
||||
|
||||
export const useK8sStore = defineStore("k8s", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
const namespaces = computed(() => {
|
||||
const namespacedContainers: Record<string, Container[]> = {};
|
||||
for (const container of runningContainers.value) {
|
||||
const namespace = container.labels["namespace"];
|
||||
if (namespace === undefined) continue;
|
||||
namespacedContainers[namespace] ||= [];
|
||||
namespacedContainers[namespace].push(container);
|
||||
}
|
||||
|
||||
const newNamespaces: K8sNamespace[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of containers) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
}
|
||||
|
||||
const newOwners: K8sOwner[] = [];
|
||||
|
||||
for (const [key, containers] of Object.entries(ownerGroups)) {
|
||||
const [kind, name] = key.split(":");
|
||||
newOwners.push(new K8sOwner(name, kind, containers));
|
||||
}
|
||||
|
||||
if (newOwners.length === 0) continue;
|
||||
|
||||
newNamespaces.push(
|
||||
new K8sNamespace(
|
||||
name,
|
||||
containers,
|
||||
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return newNamespaces.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
const namespace = container.labels["namespace"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
if (namespace) {
|
||||
// Skip containers that are already part of a namespace
|
||||
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
||||
if (hasNamespace) continue;
|
||||
}
|
||||
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
}
|
||||
|
||||
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
||||
|
||||
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
|
||||
const [kind, name] = key.split(":");
|
||||
return new K8sOwner(name, kind, containers);
|
||||
});
|
||||
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
const customGroups = computed(() => {
|
||||
const grouped: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const group = container.customGroup;
|
||||
if (group === undefined) continue;
|
||||
grouped[group] ||= [];
|
||||
grouped[group].push(container);
|
||||
}
|
||||
|
||||
return Object.entries(grouped).map(([name, containers]) => new GroupedContainers(name, containers));
|
||||
});
|
||||
|
||||
return {
|
||||
namespaces,
|
||||
owners,
|
||||
customGroups,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useK8sStore, import.meta.hot));
|
||||
}
|
||||
@@ -79,6 +79,20 @@ declare module 'vue-router/auto-routes' {
|
||||
{ ids: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/namespace/[name]': RouteRecordInfo<
|
||||
'/namespace/[name]',
|
||||
'/namespace/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/owner/[name]': RouteRecordInfo<
|
||||
'/owner/[name]',
|
||||
'/owner/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/service/[name]': RouteRecordInfo<
|
||||
'/service/[name]',
|
||||
'/service/:name',
|
||||
@@ -168,6 +182,18 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/namespace/[name].vue': {
|
||||
routes:
|
||||
| '/namespace/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/owner/[name].vue': {
|
||||
routes:
|
||||
| '/owner/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/service/[name].vue': {
|
||||
routes:
|
||||
| '/service/[name]'
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface ContainerStat {
|
||||
readonly cpu: number;
|
||||
readonly memory: number;
|
||||
readonly memoryUsage: number;
|
||||
readonly networkRxTotal: number;
|
||||
readonly networkTxTotal: number;
|
||||
}
|
||||
|
||||
export type ContainerJson = {
|
||||
|
||||
@@ -2,7 +2,7 @@ export function formatBytes(
|
||||
bytes: number,
|
||||
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
|
||||
) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
if (bytes === 0) return short ? "0B" : "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
@@ -70,7 +70,7 @@ export function useExponentialMovingAverage<T extends Record<string, number>>(so
|
||||
ema.value = newValue as T;
|
||||
});
|
||||
|
||||
return ema;
|
||||
return { movingAverage: ema, reset: (value: T) => (ema.value = value) };
|
||||
}
|
||||
|
||||
interface UseSimpleRefHistoryOptions<T> {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
title: Container Actions
|
||||
---
|
||||
|
||||
# Using Container Actions
|
||||
# Container Actions
|
||||
|
||||
<Badge type="warning" text="Docker Only" />
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
title: Agent Mode
|
||||
---
|
||||
|
||||
# Agent Mode <Badge type="warning" text="Docker Only" />
|
||||
# Agent Mode
|
||||
|
||||
<Badge type="warning" text="Docker Only" />
|
||||
|
||||
Dozzle can run in agent mode which can expose Docker hosts to other Dozzle instances. All communication is done over a secured connection using TLS. This means that you can deploy Dozzle on a remote host and connect to it from your local machine.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Authentication
|
||||
---
|
||||
|
||||
# Setting Up Authentication <Badge type="tip" text="Updated" />
|
||||
# Authentication
|
||||
|
||||
Dozzle supports two configurations for authentication. In the first configuration, you bring your own authentication method by protecting Dozzle through a proxy. Dozzle can read appropriate headers out of the box.
|
||||
|
||||
|
||||
@@ -115,3 +115,23 @@ By default, Dozzle only shows running containers. To see stopped containers, you
|
||||
## Is there a way to sync my settings across multiple instances of Dozzle?
|
||||
|
||||
In single-user mode, Dozzle stores the settings in the browser's local storage. This means that the settings are only available on the browser where they were set. For Dozzle to enable syncing settings across multiple instances, it needs to know who the user is. In multi-user mode, Dozzle uses the user's username to store the settings on disk and sync them across multiple instances. This information is stored in `/data` directory. If you want to sync settings across multiple instances, you need to [enable](/guide/authentication) multi-user mode and provide a username.
|
||||
|
||||
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
|
||||
|
||||
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
|
||||
|
||||
```
|
||||
services:
|
||||
logs:
|
||||
...
|
||||
networks: [ traefik, dozzle ]
|
||||
...
|
||||
|
||||
networks:
|
||||
dozzle:
|
||||
driver: overlay
|
||||
traefik:
|
||||
external: true
|
||||
```
|
||||
|
||||
The external network `traefik` is the overlay network which is used for the load balancer service discovery, and we've created a new `dozzle` overlay network for the Dozzle nodes to talk to one another.
|
||||
|
||||
@@ -4,6 +4,9 @@ title: Filter
|
||||
|
||||
# Filtering Containers
|
||||
|
||||
<Badge type="tip" text="Docker" />
|
||||
<Badge type="tip" text="K8s" />
|
||||
|
||||
Dozzle supports conditional filtering similar to Docker's [--filter](https://docs.docker.com/reference/cli/docker/container/ls/#filter) with `DOZZLE_FILTER` or `--filter`. Filters are passed directly to Docker to limit what Dozzle can see. For example, filtering by label is supported with `--filter "label=color"`, which is similar to `docker ps` command with `docker ps --filter "label=color"`.
|
||||
|
||||
::: code-group
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Kubernetes Support
|
||||
---
|
||||
|
||||
# Kubernetes Support <Badge type="warning" text="beta" /> <Badge type="tip" text="v8.11.x" />
|
||||
# Kubernetes Support
|
||||
|
||||
Dozzle now supports Kubernetes, allowing you to view logs from your Kubernetes pods. This feature is available in `v8.11` version of Dozzle.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ title: Remote Host Setup
|
||||
|
||||
# Remote Host Setup
|
||||
|
||||
<Badge type="warning" text="Docker Only" />
|
||||
|
||||
Dozzle supports connecting to remote Docker hosts. This is useful when running Dozzle in a container and you want to monitor a different Docker host.
|
||||
|
||||
However, with Dozzle agents, you can connect to remote hosts without exposing the Docker socket. See the [agent](/guide/agent) page for more information.
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
title: Container Shell Access
|
||||
---
|
||||
|
||||
# Attaching and Executing Commands <Badge type="info" text="new" />
|
||||
# Attaching and Executing Shell Commands
|
||||
|
||||
<Badge type="tip" text="Docker" />
|
||||
<Badge type="tip" text="K8s" />
|
||||
|
||||
Dozzle supports attaching or executing commands within containers. It provides a web-based interface to interact with Docker containers, allowing users to attach to running containers and execute commands directly from the browser. This feature is particularly useful for debugging and troubleshooting containerized applications. This feature is **disabled** by default as it may pose security risks. To enable it, set the `DOZZLE_ENABLE_SHELL` environment variable to `true`.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ title: Swarm Mode
|
||||
|
||||
# Swarm Mode
|
||||
|
||||
<Badge type="warning" text="Docker Only" />
|
||||
|
||||
Dozzle supports Docker Swarm Mode starting from version 8. When using Swarm Mode, Dozzle will automatically discover services and custom groups. Dozzle does not use Swarm API internally as it is [limited](https://github.com/moby/moby/issues/33183). Instead, Dozzle implements its own grouping using swarm labels. Additionally, Dozzle merges stats for containers in a group. This means that you can see logs and stats for all containers in a group in one view. However, it does mean each host needs to be set up with Dozzle.
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
@@ -4,8 +4,20 @@ title: What is Dozzle?
|
||||
|
||||
# What is Dozzle?
|
||||
|
||||
Dozzle is an open-source project sponsored by Docker OSS. It is a log viewer designed to simplify monitoring and debugging containers. This lightweight, web-based application offers real-time log streaming, filtering, and searching capabilities through an intuitive user interface.
|
||||
Dozzle is an open-source project sponsored by Docker OSS. It is a lightweight, web-based log viewer designed to simplify monitoring and debugging containerized applications across Docker, Docker Swarm, and Kubernetes environments.
|
||||
|
||||
Users can quickly access logs generated by their Docker containers, making it an essential tool for debugging and troubleshooting applications in a Docker environment. By default, Dozzle supports JSON logs with intelligent color coding.
|
||||
## Key Features
|
||||
|
||||
Dozzle is easy to install and configure, making it an ideal solution for developers and system administrators seeking an efficient, user-friendly log viewer for their Docker environment. The tool is available under the MIT license and is actively maintained by its developer, Amir Raminfar.
|
||||
**Real-time Monitoring**: Stream logs from running containers with instant updates through an intuitive web interface. Monitor CPU, memory, and network usage with live metrics and historical visualizations.
|
||||
|
||||
**Flexible Deployment**: Deploy as a standalone server for single or multi-host Docker monitoring, enable automatic discovery in Docker Swarm clusters, or monitor pod logs in Kubernetes environments.
|
||||
|
||||
**Advanced Log Handling**: Automatically detects and formats JSON logs with intelligent color coding. Supports simple text logs, structured JSON logs, and multi-line grouped entries with powerful filtering and search capabilities.
|
||||
|
||||
**Multi-Host Support**: Monitor containers across multiple Docker hosts simultaneously through a distributed agent architecture using gRPC.
|
||||
|
||||
**Interactive Terminal**: Attach to running containers or execute commands directly through the web interface.
|
||||
|
||||
**Lightweight & Fast**: Built with Go backend and Vue 3 frontend, Dozzle uses efficient streaming protocols (SSE/WebSocket) and requires minimal resources.
|
||||
|
||||
Dozzle is easy to install and configure, making it an ideal solution for developers and system administrators seeking an efficient log viewer for their containerized environments. The tool is available under the MIT license and is actively maintained by its developer, Amir Raminfar.
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
VM_NAME="${1:-dozzle-agent}"
|
||||
DISTRO="${2:-ubuntu}"
|
||||
AGENT_PORT="${3:-7007}"
|
||||
SHARED_CERT="./shared_cert.pem"
|
||||
SHARED_KEY="./shared_key.pem"
|
||||
|
||||
echo "🚀 Setting up Dozzle Agent on OrbStack VM: $VM_NAME"
|
||||
|
||||
# Verify shared certificates exist
|
||||
if [ ! -f "$SHARED_CERT" ]; then
|
||||
echo "❌ Shared certificate not found at $SHARED_CERT"
|
||||
echo " Run 'make generate' to create certificates"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SHARED_KEY" ]; then
|
||||
echo "❌ Shared key not found at $SHARED_KEY"
|
||||
echo " Run 'make generate' to create certificates"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Found shared certificates"
|
||||
|
||||
# Step 1: Create the VM
|
||||
echo "📦 Creating VM..."
|
||||
if orb list | grep -q "^$VM_NAME"; then
|
||||
echo "⚠️ VM $VM_NAME already exists. Delete it first with: orb delete $VM_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
orb create "$DISTRO" "$VM_NAME"
|
||||
echo "✅ VM created"
|
||||
|
||||
# Wait for VM to be ready
|
||||
sleep 3
|
||||
|
||||
# Step 2: Install Docker in the VM
|
||||
echo "🐳 Installing Docker..."
|
||||
if ! orb exec -m "$VM_NAME" bash -c '
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $(whoami)
|
||||
'; then
|
||||
echo "❌ Docker installation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Docker installed"
|
||||
|
||||
# Step 3: Copy shared certificates to VM
|
||||
echo "🔐 Copying shared certificates to VM..."
|
||||
orb exec -m "$VM_NAME" bash -c 'mkdir -p ~/dozzle-certs'
|
||||
|
||||
echo " Copying shared_cert.pem..."
|
||||
cat "$SHARED_CERT" | orb exec -m "$VM_NAME" bash -c 'cat > ~/dozzle-certs/shared_cert.pem'
|
||||
|
||||
echo " Copying shared_key.pem..."
|
||||
cat "$SHARED_KEY" | orb exec -m "$VM_NAME" bash -c 'cat > ~/dozzle-certs/shared_key.pem'
|
||||
|
||||
echo "✅ Certificates copied"
|
||||
|
||||
# Step 4: Start Dozzle agent
|
||||
echo "🎯 Starting Dozzle agent..."
|
||||
if ! orb exec -m "$VM_NAME" bash -c "
|
||||
set -e
|
||||
docker pull amir20/dozzle:latest
|
||||
docker run -d --name dozzle-agent \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/dozzle-certs:/certs \
|
||||
-p $AGENT_PORT:7007 \
|
||||
amir20/dozzle:latest agent \
|
||||
--cert /certs/shared_cert.pem \
|
||||
--key /certs/shared_key.pem
|
||||
"; then
|
||||
echo "❌ Failed to start Dozzle agent"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Dozzle agent started"
|
||||
|
||||
# Step 5: Wait for agent to be ready
|
||||
echo "⏳ Waiting for agent to be ready..."
|
||||
sleep 3
|
||||
|
||||
# Step 6: Verify agent is running
|
||||
echo "🧪 Verifying agent is running..."
|
||||
if orb exec -m "$VM_NAME" docker ps --filter name=dozzle-agent --format "{{.Status}}" | grep -q "Up"; then
|
||||
echo "✅ Agent is running"
|
||||
else
|
||||
echo "❌ Agent failed to start. Check logs with: orb exec -m $VM_NAME docker logs dozzle-agent"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print usage instructions
|
||||
echo ""
|
||||
echo "🎉 Setup complete!"
|
||||
echo ""
|
||||
echo "Dozzle agent is running on:"
|
||||
echo " $VM_NAME.orb.local:$AGENT_PORT"
|
||||
echo ""
|
||||
echo "To connect from your Dozzle instance, add this remote agent:"
|
||||
echo ""
|
||||
echo " docker run -v /var/run/docker.sock:/var/run/docker.sock \\"
|
||||
echo " -v $PWD/shared_cert.pem:/shared_cert.pem:ro \\"
|
||||
echo " -v $PWD/shared_key.pem:/shared_key.pem:ro \\"
|
||||
echo " -p 8080:8080 \\"
|
||||
echo " amir20/dozzle:latest \\"
|
||||
echo " --remote-agent $VM_NAME.orb.local:$AGENT_PORT \\"
|
||||
echo " --cert /shared_cert.pem --key /shared_key.pem"
|
||||
echo ""
|
||||
echo "Or use environment variables in docker-compose.yml:"
|
||||
echo ""
|
||||
echo " DOZZLE_REMOTE_AGENT: $VM_NAME.orb.local:$AGENT_PORT"
|
||||
echo " DOZZLE_CERT: /shared_cert.pem"
|
||||
echo " DOZZLE_KEY: /shared_key.pem"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " View agent logs: orb exec -m $VM_NAME docker logs -f dozzle-agent"
|
||||
echo " Stop agent: orb exec -m $VM_NAME docker stop dozzle-agent"
|
||||
echo " Start agent: orb exec -m $VM_NAME docker start dozzle-agent"
|
||||
echo " Remove agent: orb exec -m $VM_NAME docker rm -f dozzle-agent"
|
||||
echo " Delete VM: orb delete $VM_NAME"
|
||||
@@ -2,7 +2,7 @@ module github.com/amir20/dozzle
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/alexflint/go-arg v1.6.0
|
||||
github.com/alexflint/go-arg v1.6.1
|
||||
github.com/beme/abide v0.0.0-20190723115211-635a09831760
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,15 +33,15 @@ require (
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/sync v0.18.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
k8s.io/api v0.34.2
|
||||
k8s.io/apimachinery v0.34.2
|
||||
k8s.io/client-go v0.34.2
|
||||
k8s.io/metrics v0.34.2
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/sync v0.19.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
k8s.io/api v0.35.0
|
||||
k8s.io/apimachinery v0.35.0
|
||||
k8s.io/client-go v0.35.0
|
||||
k8s.io/metrics v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -57,6 +57,7 @@ require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/expr-lang/expr v1.17.7 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -91,7 +92,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
|
||||
@@ -99,24 +100,24 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.10.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gotest.tools/v3 v3.0.3 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.5
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8
|
||||
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
|
||||
github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo=
|
||||
github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
|
||||
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
|
||||
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
|
||||
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
@@ -76,6 +78,8 @@ github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtz
|
||||
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
@@ -247,6 +251,8 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
@@ -278,6 +284,10 @@ github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.14 h1:9F3UqVQdZ5GG5y6TU0l1TbbDhZmqfevaOcinQt88Qi8=
|
||||
github.com/yuin/goldmark v1.7.14/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -326,6 +336,8 @@ go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lI
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -354,6 +366,8 @@ golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -419,6 +433,8 @@ golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -449,6 +465,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -472,6 +490,8 @@ golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -497,6 +517,8 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -528,6 +550,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
|
||||
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
|
||||
@@ -548,6 +572,8 @@ google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
@@ -558,12 +584,16 @@ google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7I
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -592,6 +622,10 @@ k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
||||
k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4=
|
||||
k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk=
|
||||
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
|
||||
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
|
||||
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
|
||||
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
|
||||
k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=
|
||||
@@ -610,6 +644,10 @@ k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=
|
||||
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
||||
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU=
|
||||
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
|
||||
k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98=
|
||||
@@ -628,6 +666,10 @@ k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||
k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=
|
||||
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
|
||||
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
|
||||
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg=
|
||||
@@ -636,6 +678,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/metrics v0.32.3 h1:2vsBvw0v8rIIlczZ/lZ8Kcqk9tR6Fks9h+dtFNbc2a4=
|
||||
k8s.io/metrics v0.32.3/go.mod h1:9R1Wk5cb+qJpCQon9h52mgkVCcFeYxcY+YkumfwHVCU=
|
||||
k8s.io/metrics v0.33.0 h1:sKe5sC9qb1RakMhs8LWYNuN2ne6OTCWexj8Jos3rO2Y=
|
||||
@@ -654,12 +698,20 @@ k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE=
|
||||
k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80=
|
||||
k8s.io/metrics v0.34.2 h1:zao91FNDVPRGIiHLO2vqqe21zZVPien1goyzn0hsz90=
|
||||
k8s.io/metrics v0.34.2/go.mod h1:Ydulln+8uZZctUM8yrUQX4rfq/Ay6UzsuXf24QJ37Vc=
|
||||
k8s.io/metrics v0.34.3 h1:zKco9A0q7Ibl3alcO1kqRandTt4GKwKGOBflYJTIBHc=
|
||||
k8s.io/metrics v0.34.3/go.mod h1:BWmkYCQ9x4I120OmCtMUeuXn0VTGkJLwBErneDL5aSQ=
|
||||
k8s.io/metrics v0.35.0 h1:xVFoqtAGm2dMNJAcB5TFZJPCen0uEqqNt52wW7ABbX8=
|
||||
k8s.io/metrics v0.35.0/go.mod h1:g2Up4dcBygZi2kQSEQVDByFs+VUwepJMzzQLJJLpq4M=
|
||||
k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0=
|
||||
k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
|
||||
@@ -142,12 +142,25 @@ func sendLogs(stream pb.AgentService_StreamLogsClient, events chan<- *container.
|
||||
}
|
||||
|
||||
var message any
|
||||
var logType container.LogType
|
||||
switch m := m.(type) {
|
||||
case *pb.SimpleMessage:
|
||||
case *pb.SingleMessage:
|
||||
message = m.Message
|
||||
logType = container.LogTypeSingle
|
||||
|
||||
case *pb.GroupMessage:
|
||||
fragments := make([]container.LogFragment, len(m.Fragments))
|
||||
for i, f := range m.Fragments {
|
||||
fragments[i] = container.LogFragment{
|
||||
Message: f.Message,
|
||||
}
|
||||
}
|
||||
message = fragments
|
||||
logType = container.LogTypeGroup
|
||||
|
||||
case *pb.ComplexMessage:
|
||||
message = jsonBytesToOrderedMap(m.Data)
|
||||
logType = container.LogTypeComplex
|
||||
|
||||
default:
|
||||
log.Error().Type("message", m).Msg("agent client: unknown message type")
|
||||
@@ -158,8 +171,8 @@ func sendLogs(stream pb.AgentService_StreamLogsClient, events chan<- *container.
|
||||
Id: resp.Event.Id,
|
||||
ContainerID: resp.Event.ContainerId,
|
||||
Message: message,
|
||||
Type: logType,
|
||||
Timestamp: resp.Event.Timestamp.AsTime().Unix(),
|
||||
Position: container.LogPosition(resp.Event.Position),
|
||||
Level: resp.Event.Level,
|
||||
Stream: resp.Event.Stream,
|
||||
RawMessage: resp.Event.RawMessage,
|
||||
@@ -334,21 +347,82 @@ func (c *Client) ContainerAction(ctx context.Context, containerId string, action
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) ContainerAttach(ctx context.Context, containerId string) (io.WriteCloser, io.Reader, error) {
|
||||
panic("not implemented")
|
||||
func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*container.ExecSession, error) {
|
||||
stream, err := c.client.ContainerAttach(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = stream.Send(&pb.ContainerAttachRequest{
|
||||
ContainerId: containerId,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdoutReader, stdoutWriter := io.Pipe()
|
||||
stdinReader, stdinWriter := io.Pipe()
|
||||
|
||||
go func() {
|
||||
defer stdoutWriter.Close()
|
||||
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
stdoutWriter.Write(msg.Stdout)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buffer := make([]byte, 1024)
|
||||
|
||||
for {
|
||||
n, err := stdinReader.Read(buffer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := stream.Send(&pb.ContainerAttachRequest{
|
||||
Payload: &pb.ContainerAttachRequest_Stdin{
|
||||
Stdin: buffer[:n],
|
||||
},
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Create resize closure that sends via gRPC
|
||||
resizeFn := func(width uint, height uint) error {
|
||||
return stream.Send(&pb.ContainerAttachRequest{
|
||||
Payload: &pb.ContainerAttachRequest_Resize{
|
||||
Resize: &pb.ResizePayload{
|
||||
Width: uint32(width),
|
||||
Height: uint32(height),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &container.ExecSession{
|
||||
Writer: stdinWriter,
|
||||
Reader: stdoutReader,
|
||||
Resize: resizeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ContainerExec(ctx context.Context, containerId string, cmd []string) (io.WriteCloser, io.Reader, error) {
|
||||
func (c *Client) ContainerExec(ctx context.Context, containerId string, cmd []string) (*container.ExecSession, error) {
|
||||
stream, err := c.client.ContainerExec(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = stream.Send(&pb.ContainerExecRequest{
|
||||
ContainerId: containerId,
|
||||
Command: cmd,
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
stdoutReader, stdoutWriter := io.Pipe()
|
||||
stdinReader, stdinWriter := io.Pipe()
|
||||
@@ -376,14 +450,32 @@ func (c *Client) ContainerExec(ctx context.Context, containerId string, cmd []st
|
||||
}
|
||||
|
||||
if err := stream.Send(&pb.ContainerExecRequest{
|
||||
Stdin: buffer[:n],
|
||||
Payload: &pb.ContainerExecRequest_Stdin{
|
||||
Stdin: buffer[:n],
|
||||
},
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return stdinWriter, stdoutReader, nil
|
||||
// Create resize closure that sends via gRPC
|
||||
resizeFn := func(width uint, height uint) error {
|
||||
return stream.Send(&pb.ContainerExecRequest{
|
||||
Payload: &pb.ContainerExecRequest_Resize{
|
||||
Resize: &pb.ResizePayload{
|
||||
Width: uint32(width),
|
||||
Height: uint32(height),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &container.ExecSession{
|
||||
Writer: stdinWriter,
|
||||
Reader: stdoutReader,
|
||||
Resize: resizeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.3
|
||||
// source: rpc.proto
|
||||
|
||||
package pb
|
||||
@@ -943,10 +943,14 @@ func (*ContainerActionResponse) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type ContainerExecRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
|
||||
Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"`
|
||||
Stdin []byte `protobuf:"bytes,3,opt,name=stdin,proto3" json:"stdin,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
|
||||
Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
//
|
||||
// *ContainerExecRequest_Stdin
|
||||
// *ContainerExecRequest_Resize
|
||||
Payload isContainerExecRequest_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -995,13 +999,99 @@ func (x *ContainerExecRequest) GetCommand() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ContainerExecRequest) GetStdin() []byte {
|
||||
func (x *ContainerExecRequest) GetPayload() isContainerExecRequest_Payload {
|
||||
if x != nil {
|
||||
return x.Stdin
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ContainerExecRequest) GetStdin() []byte {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*ContainerExecRequest_Stdin); ok {
|
||||
return x.Stdin
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ContainerExecRequest) GetResize() *ResizePayload {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*ContainerExecRequest_Resize); ok {
|
||||
return x.Resize
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isContainerExecRequest_Payload interface {
|
||||
isContainerExecRequest_Payload()
|
||||
}
|
||||
|
||||
type ContainerExecRequest_Stdin struct {
|
||||
Stdin []byte `protobuf:"bytes,3,opt,name=stdin,proto3,oneof"`
|
||||
}
|
||||
|
||||
type ContainerExecRequest_Resize struct {
|
||||
Resize *ResizePayload `protobuf:"bytes,4,opt,name=resize,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*ContainerExecRequest_Stdin) isContainerExecRequest_Payload() {}
|
||||
|
||||
func (*ContainerExecRequest_Resize) isContainerExecRequest_Payload() {}
|
||||
|
||||
type ResizePayload struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Width uint32 `protobuf:"varint,1,opt,name=width,proto3" json:"width,omitempty"`
|
||||
Height uint32 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ResizePayload) Reset() {
|
||||
*x = ResizePayload{}
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ResizePayload) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ResizePayload) ProtoMessage() {}
|
||||
|
||||
func (x *ResizePayload) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ResizePayload.ProtoReflect.Descriptor instead.
|
||||
func (*ResizePayload) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *ResizePayload) GetWidth() uint32 {
|
||||
if x != nil {
|
||||
return x.Width
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ResizePayload) GetHeight() uint32 {
|
||||
if x != nil {
|
||||
return x.Height
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ContainerExecResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3" json:"stdout,omitempty"`
|
||||
@@ -1011,7 +1101,7 @@ type ContainerExecResponse struct {
|
||||
|
||||
func (x *ContainerExecResponse) Reset() {
|
||||
*x = ContainerExecResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
mi := &file_rpc_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1023,7 +1113,7 @@ func (x *ContainerExecResponse) String() string {
|
||||
func (*ContainerExecResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerExecResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
mi := &file_rpc_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1036,7 +1126,7 @@ func (x *ContainerExecResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ContainerExecResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerExecResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{21}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
func (x *ContainerExecResponse) GetStdout() []byte {
|
||||
@@ -1046,6 +1136,140 @@ func (x *ContainerExecResponse) GetStdout() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
type ContainerAttachRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
//
|
||||
// *ContainerAttachRequest_Stdin
|
||||
// *ContainerAttachRequest_Resize
|
||||
Payload isContainerAttachRequest_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) Reset() {
|
||||
*x = ContainerAttachRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ContainerAttachRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerAttachRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ContainerAttachRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerAttachRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{23}
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) GetContainerId() string {
|
||||
if x != nil {
|
||||
return x.ContainerId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) GetPayload() isContainerAttachRequest_Payload {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) GetStdin() []byte {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*ContainerAttachRequest_Stdin); ok {
|
||||
return x.Stdin
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) GetResize() *ResizePayload {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*ContainerAttachRequest_Resize); ok {
|
||||
return x.Resize
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isContainerAttachRequest_Payload interface {
|
||||
isContainerAttachRequest_Payload()
|
||||
}
|
||||
|
||||
type ContainerAttachRequest_Stdin struct {
|
||||
Stdin []byte `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"`
|
||||
}
|
||||
|
||||
type ContainerAttachRequest_Resize struct {
|
||||
Resize *ResizePayload `protobuf:"bytes,3,opt,name=resize,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*ContainerAttachRequest_Stdin) isContainerAttachRequest_Payload() {}
|
||||
|
||||
func (*ContainerAttachRequest_Resize) isContainerAttachRequest_Payload() {}
|
||||
|
||||
type ContainerAttachResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3" json:"stdout,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ContainerAttachResponse) Reset() {
|
||||
*x = ContainerAttachResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[24]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ContainerAttachResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ContainerAttachResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerAttachResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[24]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ContainerAttachResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerAttachResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{24}
|
||||
}
|
||||
|
||||
func (x *ContainerAttachResponse) GetStdout() []byte {
|
||||
if x != nil {
|
||||
return x.Stdout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_rpc_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_rpc_proto_rawDesc = "" +
|
||||
@@ -1103,13 +1327,25 @@ const file_rpc_proto_rawDesc = "" +
|
||||
"\x16ContainerActionRequest\x12 \n" +
|
||||
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x121\n" +
|
||||
"\x06action\x18\x02 \x01(\x0e2\x19.protobuf.ContainerActionR\x06action\"\x19\n" +
|
||||
"\x17ContainerActionResponse\"h\n" +
|
||||
"\x17ContainerActionResponse\"\xa8\x01\n" +
|
||||
"\x14ContainerExecRequest\x12 \n" +
|
||||
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x12\x18\n" +
|
||||
"\acommand\x18\x02 \x03(\tR\acommand\x12\x14\n" +
|
||||
"\x05stdin\x18\x03 \x01(\fR\x05stdin\"/\n" +
|
||||
"\acommand\x18\x02 \x03(\tR\acommand\x12\x16\n" +
|
||||
"\x05stdin\x18\x03 \x01(\fH\x00R\x05stdin\x121\n" +
|
||||
"\x06resize\x18\x04 \x01(\v2\x17.protobuf.ResizePayloadH\x00R\x06resizeB\t\n" +
|
||||
"\apayload\"=\n" +
|
||||
"\rResizePayload\x12\x14\n" +
|
||||
"\x05width\x18\x01 \x01(\rR\x05width\x12\x16\n" +
|
||||
"\x06height\x18\x02 \x01(\rR\x06height\"/\n" +
|
||||
"\x15ContainerExecResponse\x12\x16\n" +
|
||||
"\x06stdout\x18\x01 \x01(\fR\x06stdout2\xc3\a\n" +
|
||||
"\x06stdout\x18\x01 \x01(\fR\x06stdout\"\x90\x01\n" +
|
||||
"\x16ContainerAttachRequest\x12 \n" +
|
||||
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x12\x16\n" +
|
||||
"\x05stdin\x18\x02 \x01(\fH\x00R\x05stdin\x121\n" +
|
||||
"\x06resize\x18\x03 \x01(\v2\x17.protobuf.ResizePayloadH\x00R\x06resizeB\t\n" +
|
||||
"\apayload\"1\n" +
|
||||
"\x17ContainerAttachResponse\x12\x16\n" +
|
||||
"\x06stdout\x18\x01 \x01(\fR\x06stdout2\xa1\b\n" +
|
||||
"\fAgentService\x12U\n" +
|
||||
"\x0eListContainers\x12\x1f.protobuf.ListContainersRequest\x1a .protobuf.ListContainersResponse\"\x00\x12R\n" +
|
||||
"\rFindContainer\x12\x1e.protobuf.FindContainerRequest\x1a\x1f.protobuf.FindContainerResponse\"\x00\x12K\n" +
|
||||
@@ -1122,7 +1358,8 @@ const file_rpc_proto_rawDesc = "" +
|
||||
"\x16StreamContainerStarted\x12'.protobuf.StreamContainerStartedRequest\x1a(.protobuf.StreamContainerStartedResponse\"\x000\x01\x12C\n" +
|
||||
"\bHostInfo\x12\x19.protobuf.HostInfoRequest\x1a\x1a.protobuf.HostInfoResponse\"\x00\x12X\n" +
|
||||
"\x0fContainerAction\x12 .protobuf.ContainerActionRequest\x1a!.protobuf.ContainerActionResponse\"\x00\x12V\n" +
|
||||
"\rContainerExec\x12\x1e.protobuf.ContainerExecRequest\x1a\x1f.protobuf.ContainerExecResponse\"\x00(\x010\x01B\x13Z\x11internal/agent/pbb\x06proto3"
|
||||
"\rContainerExec\x12\x1e.protobuf.ContainerExecRequest\x1a\x1f.protobuf.ContainerExecResponse\"\x00(\x010\x01\x12\\\n" +
|
||||
"\x0fContainerAttach\x12 .protobuf.ContainerAttachRequest\x1a!.protobuf.ContainerAttachResponse\"\x00(\x010\x01B\x13Z\x11internal/agent/pbb\x06proto3"
|
||||
|
||||
var (
|
||||
file_rpc_proto_rawDescOnce sync.Once
|
||||
@@ -1136,7 +1373,7 @@ func file_rpc_proto_rawDescGZIP() []byte {
|
||||
return file_rpc_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
|
||||
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
|
||||
var file_rpc_proto_goTypes = []any{
|
||||
(*ListContainersRequest)(nil), // 0: protobuf.ListContainersRequest
|
||||
(*RepeatedString)(nil), // 1: protobuf.RepeatedString
|
||||
@@ -1159,62 +1396,69 @@ var file_rpc_proto_goTypes = []any{
|
||||
(*ContainerActionRequest)(nil), // 18: protobuf.ContainerActionRequest
|
||||
(*ContainerActionResponse)(nil), // 19: protobuf.ContainerActionResponse
|
||||
(*ContainerExecRequest)(nil), // 20: protobuf.ContainerExecRequest
|
||||
(*ContainerExecResponse)(nil), // 21: protobuf.ContainerExecResponse
|
||||
nil, // 22: protobuf.ListContainersRequest.FilterEntry
|
||||
nil, // 23: protobuf.FindContainerRequest.FilterEntry
|
||||
(*Container)(nil), // 24: protobuf.Container
|
||||
(*timestamppb.Timestamp)(nil), // 25: google.protobuf.Timestamp
|
||||
(*LogEvent)(nil), // 26: protobuf.LogEvent
|
||||
(*ContainerEvent)(nil), // 27: protobuf.ContainerEvent
|
||||
(*ContainerStat)(nil), // 28: protobuf.ContainerStat
|
||||
(*Host)(nil), // 29: protobuf.Host
|
||||
(ContainerAction)(0), // 30: protobuf.ContainerAction
|
||||
(*ResizePayload)(nil), // 21: protobuf.ResizePayload
|
||||
(*ContainerExecResponse)(nil), // 22: protobuf.ContainerExecResponse
|
||||
(*ContainerAttachRequest)(nil), // 23: protobuf.ContainerAttachRequest
|
||||
(*ContainerAttachResponse)(nil), // 24: protobuf.ContainerAttachResponse
|
||||
nil, // 25: protobuf.ListContainersRequest.FilterEntry
|
||||
nil, // 26: protobuf.FindContainerRequest.FilterEntry
|
||||
(*Container)(nil), // 27: protobuf.Container
|
||||
(*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp
|
||||
(*LogEvent)(nil), // 29: protobuf.LogEvent
|
||||
(*ContainerEvent)(nil), // 30: protobuf.ContainerEvent
|
||||
(*ContainerStat)(nil), // 31: protobuf.ContainerStat
|
||||
(*Host)(nil), // 32: protobuf.Host
|
||||
(ContainerAction)(0), // 33: protobuf.ContainerAction
|
||||
}
|
||||
var file_rpc_proto_depIdxs = []int32{
|
||||
22, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
|
||||
24, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
|
||||
23, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
|
||||
24, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
|
||||
25, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
|
||||
26, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
|
||||
25, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
25, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
25, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
25, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
27, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
|
||||
28, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
|
||||
29, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
|
||||
24, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
|
||||
30, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
|
||||
1, // 15: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
1, // 16: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
0, // 17: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
|
||||
3, // 18: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
|
||||
5, // 19: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
|
||||
7, // 20: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
|
||||
8, // 21: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
|
||||
10, // 22: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
|
||||
12, // 23: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
|
||||
16, // 24: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
|
||||
14, // 25: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
|
||||
18, // 26: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
|
||||
20, // 27: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
|
||||
2, // 28: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
|
||||
4, // 29: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
|
||||
6, // 30: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
|
||||
6, // 31: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
|
||||
9, // 32: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
|
||||
11, // 33: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
|
||||
13, // 34: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
|
||||
17, // 35: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
|
||||
15, // 36: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
|
||||
19, // 37: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
|
||||
21, // 38: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
|
||||
28, // [28:39] is the sub-list for method output_type
|
||||
17, // [17:28] is the sub-list for method input_type
|
||||
17, // [17:17] is the sub-list for extension type_name
|
||||
17, // [17:17] is the sub-list for extension extendee
|
||||
0, // [0:17] is the sub-list for field type_name
|
||||
25, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
|
||||
27, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
|
||||
26, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
|
||||
27, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
|
||||
28, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
|
||||
29, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
|
||||
28, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
28, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
28, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
28, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
30, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
|
||||
31, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
|
||||
32, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
|
||||
27, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
|
||||
33, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
|
||||
21, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
|
||||
21, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
|
||||
1, // 17: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
1, // 18: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
0, // 19: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
|
||||
3, // 20: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
|
||||
5, // 21: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
|
||||
7, // 22: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
|
||||
8, // 23: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
|
||||
10, // 24: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
|
||||
12, // 25: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
|
||||
16, // 26: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
|
||||
14, // 27: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
|
||||
18, // 28: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
|
||||
20, // 29: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
|
||||
23, // 30: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
|
||||
2, // 31: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
|
||||
4, // 32: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
|
||||
6, // 33: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
|
||||
6, // 34: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
|
||||
9, // 35: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
|
||||
11, // 36: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
|
||||
13, // 37: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
|
||||
17, // 38: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
|
||||
15, // 39: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
|
||||
19, // 40: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
|
||||
22, // 41: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
|
||||
24, // 42: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
|
||||
31, // [31:43] is the sub-list for method output_type
|
||||
19, // [19:31] is the sub-list for method input_type
|
||||
19, // [19:19] is the sub-list for extension type_name
|
||||
19, // [19:19] is the sub-list for extension extendee
|
||||
0, // [0:19] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_rpc_proto_init() }
|
||||
@@ -1223,13 +1467,21 @@ func file_rpc_proto_init() {
|
||||
return
|
||||
}
|
||||
file_types_proto_init()
|
||||
file_rpc_proto_msgTypes[20].OneofWrappers = []any{
|
||||
(*ContainerExecRequest_Stdin)(nil),
|
||||
(*ContainerExecRequest_Resize)(nil),
|
||||
}
|
||||
file_rpc_proto_msgTypes[23].OneofWrappers = []any{
|
||||
(*ContainerAttachRequest_Stdin)(nil),
|
||||
(*ContainerAttachRequest_Resize)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_proto_rawDesc), len(file_rpc_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 24,
|
||||
NumMessages: 27,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.3
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.3
|
||||
// source: rpc.proto
|
||||
|
||||
package pb
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
AgentService_HostInfo_FullMethodName = "/protobuf.AgentService/HostInfo"
|
||||
AgentService_ContainerAction_FullMethodName = "/protobuf.AgentService/ContainerAction"
|
||||
AgentService_ContainerExec_FullMethodName = "/protobuf.AgentService/ContainerExec"
|
||||
AgentService_ContainerAttach_FullMethodName = "/protobuf.AgentService/ContainerAttach"
|
||||
)
|
||||
|
||||
// AgentServiceClient is the client API for AgentService service.
|
||||
@@ -47,6 +48,7 @@ type AgentServiceClient interface {
|
||||
HostInfo(ctx context.Context, in *HostInfoRequest, opts ...grpc.CallOption) (*HostInfoResponse, error)
|
||||
ContainerAction(ctx context.Context, in *ContainerActionRequest, opts ...grpc.CallOption) (*ContainerActionResponse, error)
|
||||
ContainerExec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse], error)
|
||||
ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error)
|
||||
}
|
||||
|
||||
type agentServiceClient struct {
|
||||
@@ -224,6 +226,19 @@ func (c *agentServiceClient) ContainerExec(ctx context.Context, opts ...grpc.Cal
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_ContainerExecClient = grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse]
|
||||
|
||||
func (c *agentServiceClient) ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[7], AgentService_ContainerAttach_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[ContainerAttachRequest, ContainerAttachResponse]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_ContainerAttachClient = grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse]
|
||||
|
||||
// AgentServiceServer is the server API for AgentService service.
|
||||
// All implementations must embed UnimplementedAgentServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -239,6 +254,7 @@ type AgentServiceServer interface {
|
||||
HostInfo(context.Context, *HostInfoRequest) (*HostInfoResponse, error)
|
||||
ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error)
|
||||
ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error
|
||||
ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error
|
||||
mustEmbedUnimplementedAgentServiceServer()
|
||||
}
|
||||
|
||||
@@ -250,37 +266,40 @@ type AgentServiceServer interface {
|
||||
type UnimplementedAgentServiceServer struct{}
|
||||
|
||||
func (UnimplementedAgentServiceServer) ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListContainers not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ListContainers not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) FindContainer(context.Context, *FindContainerRequest) (*FindContainerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method FindContainer not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method FindContainer not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamLogs(*StreamLogsRequest, grpc.ServerStreamingServer[StreamLogsResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamLogs not implemented")
|
||||
return status.Error(codes.Unimplemented, "method StreamLogs not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) LogsBetweenDates(*LogsBetweenDatesRequest, grpc.ServerStreamingServer[StreamLogsResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method LogsBetweenDates not implemented")
|
||||
return status.Error(codes.Unimplemented, "method LogsBetweenDates not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamRawBytes(*StreamRawBytesRequest, grpc.ServerStreamingServer[StreamRawBytesResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamRawBytes not implemented")
|
||||
return status.Error(codes.Unimplemented, "method StreamRawBytes not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[StreamEventsResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamEvents not implemented")
|
||||
return status.Error(codes.Unimplemented, "method StreamEvents not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamStats(*StreamStatsRequest, grpc.ServerStreamingServer[StreamStatsResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamStats not implemented")
|
||||
return status.Error(codes.Unimplemented, "method StreamStats not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamContainerStarted(*StreamContainerStartedRequest, grpc.ServerStreamingServer[StreamContainerStartedResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamContainerStarted not implemented")
|
||||
return status.Error(codes.Unimplemented, "method StreamContainerStarted not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) HostInfo(context.Context, *HostInfoRequest) (*HostInfoResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method HostInfo not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method HostInfo not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ContainerAction not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ContainerAction not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ContainerExec not implemented")
|
||||
return status.Error(codes.Unimplemented, "method ContainerExec not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error {
|
||||
return status.Error(codes.Unimplemented, "method ContainerAttach not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
|
||||
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
|
||||
@@ -293,7 +312,7 @@ type UnsafeAgentServiceServer interface {
|
||||
}
|
||||
|
||||
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedAgentServiceServer was
|
||||
// If the following call panics, it indicates UnimplementedAgentServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
@@ -448,6 +467,13 @@ func _AgentService_ContainerExec_Handler(srv interface{}, stream grpc.ServerStre
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_ContainerExecServer = grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]
|
||||
|
||||
func _AgentService_ContainerAttach_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentServiceServer).ContainerAttach(&grpc.GenericServerStream[ContainerAttachRequest, ContainerAttachResponse]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_ContainerAttachServer = grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]
|
||||
|
||||
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -509,6 +535,12 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "ContainerAttach",
|
||||
Handler: _AgentService_ContainerAttach_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "rpc.proto",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.3
|
||||
// source: types.proto
|
||||
|
||||
package pb
|
||||
@@ -261,13 +261,15 @@ func (x *Container) GetFullyLoaded() bool {
|
||||
}
|
||||
|
||||
type ContainerStat struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
CpuPercent float64 `protobuf:"fixed64,2,opt,name=cpuPercent,proto3" json:"cpuPercent,omitempty"`
|
||||
MemoryUsage float64 `protobuf:"fixed64,3,opt,name=memoryUsage,proto3" json:"memoryUsage,omitempty"`
|
||||
MemoryPercent float64 `protobuf:"fixed64,4,opt,name=memoryPercent,proto3" json:"memoryPercent,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
CpuPercent float64 `protobuf:"fixed64,2,opt,name=cpuPercent,proto3" json:"cpuPercent,omitempty"`
|
||||
MemoryUsage float64 `protobuf:"fixed64,3,opt,name=memoryUsage,proto3" json:"memoryUsage,omitempty"`
|
||||
MemoryPercent float64 `protobuf:"fixed64,4,opt,name=memoryPercent,proto3" json:"memoryPercent,omitempty"`
|
||||
NetworkRxTotal uint64 `protobuf:"varint,5,opt,name=networkRxTotal,proto3" json:"networkRxTotal,omitempty"`
|
||||
NetworkTxTotal uint64 `protobuf:"varint,6,opt,name=networkTxTotal,proto3" json:"networkTxTotal,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ContainerStat) Reset() {
|
||||
@@ -328,15 +330,73 @@ func (x *ContainerStat) GetMemoryPercent() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ContainerStat) GetNetworkRxTotal() uint64 {
|
||||
if x != nil {
|
||||
return x.NetworkRxTotal
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ContainerStat) GetNetworkTxTotal() uint64 {
|
||||
if x != nil {
|
||||
return x.NetworkTxTotal
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type LogFragment struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LogFragment) Reset() {
|
||||
*x = LogFragment{}
|
||||
mi := &file_types_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LogFragment) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LogFragment) ProtoMessage() {}
|
||||
|
||||
func (x *LogFragment) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LogFragment.ProtoReflect.Descriptor instead.
|
||||
func (*LogFragment) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *LogFragment) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
ContainerId string `protobuf:"bytes,2,opt,name=containerId,proto3" json:"containerId,omitempty"`
|
||||
Message *anypb.Any `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
|
||||
Message *anypb.Any `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` // SingleMessage, GroupMessage, or ComplexMessage
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
Level string `protobuf:"bytes,5,opt,name=level,proto3" json:"level,omitempty"`
|
||||
Stream string `protobuf:"bytes,6,opt,name=stream,proto3" json:"stream,omitempty"`
|
||||
Position string `protobuf:"bytes,7,opt,name=position,proto3" json:"position,omitempty"`
|
||||
Type string `protobuf:"bytes,7,opt,name=type,proto3" json:"type,omitempty"` // "single", "group", or "complex"
|
||||
RawMessage string `protobuf:"bytes,8,opt,name=rawMessage,proto3" json:"rawMessage,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -344,7 +404,7 @@ type LogEvent struct {
|
||||
|
||||
func (x *LogEvent) Reset() {
|
||||
*x = LogEvent{}
|
||||
mi := &file_types_proto_msgTypes[2]
|
||||
mi := &file_types_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -356,7 +416,7 @@ func (x *LogEvent) String() string {
|
||||
func (*LogEvent) ProtoMessage() {}
|
||||
|
||||
func (x *LogEvent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[2]
|
||||
mi := &file_types_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -369,7 +429,7 @@ func (x *LogEvent) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use LogEvent.ProtoReflect.Descriptor instead.
|
||||
func (*LogEvent) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{2}
|
||||
return file_types_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *LogEvent) GetId() uint32 {
|
||||
@@ -414,9 +474,9 @@ func (x *LogEvent) GetStream() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogEvent) GetPosition() string {
|
||||
func (x *LogEvent) GetType() string {
|
||||
if x != nil {
|
||||
return x.Position
|
||||
return x.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -428,28 +488,28 @@ func (x *LogEvent) GetRawMessage() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type SimpleMessage struct {
|
||||
type SingleMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SimpleMessage) Reset() {
|
||||
*x = SimpleMessage{}
|
||||
mi := &file_types_proto_msgTypes[3]
|
||||
func (x *SingleMessage) Reset() {
|
||||
*x = SingleMessage{}
|
||||
mi := &file_types_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SimpleMessage) String() string {
|
||||
func (x *SingleMessage) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SimpleMessage) ProtoMessage() {}
|
||||
func (*SingleMessage) ProtoMessage() {}
|
||||
|
||||
func (x *SimpleMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[3]
|
||||
func (x *SingleMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -460,18 +520,62 @@ func (x *SimpleMessage) ProtoReflect() protoreflect.Message {
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SimpleMessage.ProtoReflect.Descriptor instead.
|
||||
func (*SimpleMessage) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{3}
|
||||
// Deprecated: Use SingleMessage.ProtoReflect.Descriptor instead.
|
||||
func (*SingleMessage) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *SimpleMessage) GetMessage() string {
|
||||
func (x *SingleMessage) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GroupMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Fragments []*LogFragment `protobuf:"bytes,1,rep,name=fragments,proto3" json:"fragments,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GroupMessage) Reset() {
|
||||
*x = GroupMessage{}
|
||||
mi := &file_types_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GroupMessage) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GroupMessage) ProtoMessage() {}
|
||||
|
||||
func (x *GroupMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GroupMessage.ProtoReflect.Descriptor instead.
|
||||
func (*GroupMessage) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *GroupMessage) GetFragments() []*LogFragment {
|
||||
if x != nil {
|
||||
return x.Fragments
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ComplexMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||
@@ -481,7 +585,7 @@ type ComplexMessage struct {
|
||||
|
||||
func (x *ComplexMessage) Reset() {
|
||||
*x = ComplexMessage{}
|
||||
mi := &file_types_proto_msgTypes[4]
|
||||
mi := &file_types_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -493,7 +597,7 @@ func (x *ComplexMessage) String() string {
|
||||
func (*ComplexMessage) ProtoMessage() {}
|
||||
|
||||
func (x *ComplexMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[4]
|
||||
mi := &file_types_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -506,7 +610,7 @@ func (x *ComplexMessage) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ComplexMessage.ProtoReflect.Descriptor instead.
|
||||
func (*ComplexMessage) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{4}
|
||||
return file_types_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *ComplexMessage) GetData() []byte {
|
||||
@@ -528,7 +632,7 @@ type ContainerEvent struct {
|
||||
|
||||
func (x *ContainerEvent) Reset() {
|
||||
*x = ContainerEvent{}
|
||||
mi := &file_types_proto_msgTypes[5]
|
||||
mi := &file_types_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -540,7 +644,7 @@ func (x *ContainerEvent) String() string {
|
||||
func (*ContainerEvent) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerEvent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[5]
|
||||
mi := &file_types_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -553,7 +657,7 @@ func (x *ContainerEvent) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ContainerEvent.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerEvent) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{5}
|
||||
return file_types_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *ContainerEvent) GetActorId() string {
|
||||
@@ -604,7 +708,7 @@ type Host struct {
|
||||
|
||||
func (x *Host) Reset() {
|
||||
*x = Host{}
|
||||
mi := &file_types_proto_msgTypes[6]
|
||||
mi := &file_types_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -616,7 +720,7 @@ func (x *Host) String() string {
|
||||
func (*Host) ProtoMessage() {}
|
||||
|
||||
func (x *Host) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[6]
|
||||
mi := &file_types_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -629,7 +733,7 @@ func (x *Host) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Host.ProtoReflect.Descriptor instead.
|
||||
func (*Host) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{6}
|
||||
return file_types_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *Host) GetId() string {
|
||||
@@ -720,7 +824,7 @@ var File_types_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_types_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\vtypes.proto\x12\bprotobuf\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19google/protobuf/any.proto\"\xa2\x05\n" +
|
||||
"\vtypes.proto\x12\bprotobuf\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa2\x05\n" +
|
||||
"\tContainer\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
|
||||
@@ -744,27 +848,33 @@ const file_types_proto_rawDesc = "" +
|
||||
"\vfullyLoaded\x18\x13 \x01(\bR\vfullyLoaded\x1a9\n" +
|
||||
"\vLabelsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x87\x01\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd7\x01\n" +
|
||||
"\rContainerStat\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x1e\n" +
|
||||
"\n" +
|
||||
"cpuPercent\x18\x02 \x01(\x01R\n" +
|
||||
"cpuPercent\x12 \n" +
|
||||
"\vmemoryUsage\x18\x03 \x01(\x01R\vmemoryUsage\x12$\n" +
|
||||
"\rmemoryPercent\x18\x04 \x01(\x01R\rmemoryPercent\"\x90\x02\n" +
|
||||
"\rmemoryPercent\x18\x04 \x01(\x01R\rmemoryPercent\x12&\n" +
|
||||
"\x0enetworkRxTotal\x18\x05 \x01(\x04R\x0enetworkRxTotal\x12&\n" +
|
||||
"\x0enetworkTxTotal\x18\x06 \x01(\x04R\x0enetworkTxTotal\"'\n" +
|
||||
"\vLogFragment\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"\x88\x02\n" +
|
||||
"\bLogEvent\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\x12 \n" +
|
||||
"\vcontainerId\x18\x02 \x01(\tR\vcontainerId\x12.\n" +
|
||||
"\amessage\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\amessage\x128\n" +
|
||||
"\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12\x14\n" +
|
||||
"\x05level\x18\x05 \x01(\tR\x05level\x12\x16\n" +
|
||||
"\x06stream\x18\x06 \x01(\tR\x06stream\x12\x1a\n" +
|
||||
"\bposition\x18\a \x01(\tR\bposition\x12\x1e\n" +
|
||||
"\x06stream\x18\x06 \x01(\tR\x06stream\x12\x12\n" +
|
||||
"\x04type\x18\a \x01(\tR\x04type\x12\x1e\n" +
|
||||
"\n" +
|
||||
"rawMessage\x18\b \x01(\tR\n" +
|
||||
"rawMessage\")\n" +
|
||||
"\rSimpleMessage\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"$\n" +
|
||||
"\rSingleMessage\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"C\n" +
|
||||
"\fGroupMessage\x123\n" +
|
||||
"\tfragments\x18\x01 \x03(\v2\x15.protobuf.LogFragmentR\tfragments\"$\n" +
|
||||
"\x0eComplexMessage\x12\x12\n" +
|
||||
"\x04data\x18\x01 \x01(\fR\x04data\"\x8c\x01\n" +
|
||||
"\x0eContainerEvent\x12\x18\n" +
|
||||
@@ -807,36 +917,39 @@ func file_types_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
||||
var file_types_proto_goTypes = []any{
|
||||
(ContainerAction)(0), // 0: protobuf.ContainerAction
|
||||
(*Container)(nil), // 1: protobuf.Container
|
||||
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
|
||||
(*LogEvent)(nil), // 3: protobuf.LogEvent
|
||||
(*SimpleMessage)(nil), // 4: protobuf.SimpleMessage
|
||||
(*ComplexMessage)(nil), // 5: protobuf.ComplexMessage
|
||||
(*ContainerEvent)(nil), // 6: protobuf.ContainerEvent
|
||||
(*Host)(nil), // 7: protobuf.Host
|
||||
nil, // 8: protobuf.Container.LabelsEntry
|
||||
nil, // 9: protobuf.Host.LabelsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||
(*anypb.Any)(nil), // 11: google.protobuf.Any
|
||||
(*LogFragment)(nil), // 3: protobuf.LogFragment
|
||||
(*LogEvent)(nil), // 4: protobuf.LogEvent
|
||||
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
|
||||
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
|
||||
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
|
||||
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
|
||||
(*Host)(nil), // 9: protobuf.Host
|
||||
nil, // 10: protobuf.Container.LabelsEntry
|
||||
nil, // 11: protobuf.Host.LabelsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
||||
(*anypb.Any)(nil), // 13: google.protobuf.Any
|
||||
}
|
||||
var file_types_proto_depIdxs = []int32{
|
||||
10, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
|
||||
10, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
|
||||
8, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
|
||||
12, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
|
||||
12, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
|
||||
10, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
|
||||
2, // 3: protobuf.Container.stats:type_name -> protobuf.ContainerStat
|
||||
10, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
|
||||
11, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
|
||||
10, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
10, // 7: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
9, // 8: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
|
||||
9, // [9:9] is the sub-list for method output_type
|
||||
9, // [9:9] is the sub-list for method input_type
|
||||
9, // [9:9] is the sub-list for extension type_name
|
||||
9, // [9:9] is the sub-list for extension extendee
|
||||
0, // [0:9] is the sub-list for field type_name
|
||||
12, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
|
||||
13, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
|
||||
12, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
3, // 7: protobuf.GroupMessage.fragments:type_name -> protobuf.LogFragment
|
||||
12, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
11, // 9: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
|
||||
10, // [10:10] is the sub-list for method output_type
|
||||
10, // [10:10] is the sub-list for method input_type
|
||||
10, // [10:10] is the sub-list for extension type_name
|
||||
10, // [10:10] is the sub-list for extension extendee
|
||||
0, // [0:10] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_types_proto_init() }
|
||||
@@ -850,7 +963,7 @@ func file_types_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_types_proto_rawDesc), len(file_types_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 9,
|
||||
NumMessages: 11,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -171,10 +171,12 @@ func (s *server) StreamStats(in *pb.StreamStatsRequest, out pb.AgentService_Stre
|
||||
case stat := <-stats:
|
||||
out.Send(&pb.StreamStatsResponse{
|
||||
Stat: &pb.ContainerStat{
|
||||
Id: stat.ID,
|
||||
CpuPercent: stat.CPUPercent,
|
||||
MemoryPercent: stat.MemoryPercent,
|
||||
MemoryUsage: stat.MemoryUsage,
|
||||
Id: stat.ID,
|
||||
CpuPercent: stat.CPUPercent,
|
||||
MemoryPercent: stat.MemoryPercent,
|
||||
MemoryUsage: stat.MemoryUsage,
|
||||
NetworkRxTotal: stat.NetworkRxTotal,
|
||||
NetworkTxTotal: stat.NetworkTxTotal,
|
||||
},
|
||||
})
|
||||
case <-out.Context().Done():
|
||||
@@ -289,7 +291,7 @@ func (s *server) ContainerExec(stream pb.AgentService_ContainerExecServer) error
|
||||
}
|
||||
|
||||
cancelCtx, cancel := context.WithCancel(stream.Context())
|
||||
containerWriter, containerReader, err := s.client.ContainerExec(cancelCtx, request.ContainerId, request.Command)
|
||||
session, err := s.client.ContainerExec(cancelCtx, request.ContainerId, request.Command)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
@@ -297,26 +299,12 @@ func (s *server) ContainerExec(stream pb.AgentService_ContainerExecServer) error
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
defer containerWriter.Close()
|
||||
for {
|
||||
stdinReq, err := stream.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := containerWriter.Write(stdinReq.Stdin); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read from container and send to client
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
buffer := make([]byte, 1024)
|
||||
for {
|
||||
n, err := containerReader.Read(buffer)
|
||||
n, err := session.Reader.Read(buffer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -327,6 +315,90 @@ func (s *server) ContainerExec(stream pb.AgentService_ContainerExecServer) error
|
||||
}
|
||||
})
|
||||
|
||||
// Read from client stream and handle stdin/resize
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
defer session.Writer.Close()
|
||||
for {
|
||||
req, err := stream.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch payload := req.Payload.(type) {
|
||||
case *pb.ContainerExecRequest_Stdin:
|
||||
if _, err := session.Writer.Write(payload.Stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error writing stdin to container")
|
||||
return
|
||||
}
|
||||
case *pb.ContainerExecRequest_Resize:
|
||||
if err := session.Resize(uint(payload.Resize.Width), uint(payload.Resize.Height)); err != nil {
|
||||
log.Error().Err(err).Msg("error resizing terminal")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) ContainerAttach(stream pb.AgentService_ContainerAttachServer) error {
|
||||
request, err := stream.Recv()
|
||||
if err != nil {
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
cancelCtx, cancel := context.WithCancel(stream.Context())
|
||||
session, err := s.client.ContainerAttach(cancelCtx, request.ContainerId)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Read from container and send to client
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
buffer := make([]byte, 1024)
|
||||
for {
|
||||
n, err := session.Reader.Read(buffer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := stream.Send(&pb.ContainerAttachResponse{Stdout: buffer[:n]}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read from client stream and handle stdin/resize
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
defer session.Writer.Close()
|
||||
for {
|
||||
req, err := stream.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch payload := req.Payload.(type) {
|
||||
case *pb.ContainerAttachRequest_Stdin:
|
||||
if _, err := session.Writer.Write(payload.Stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error writing stdin to container")
|
||||
return
|
||||
}
|
||||
case *pb.ContainerAttachRequest_Resize:
|
||||
if err := session.Resize(uint(payload.Resize.Width), uint(payload.Resize.Height)); err != nil {
|
||||
log.Error().Err(err).Msg("error resizing terminal")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
@@ -365,10 +437,21 @@ func logEventToPb(event *container.LogEvent) *pb.LogEvent {
|
||||
|
||||
switch data := event.Message.(type) {
|
||||
case string:
|
||||
message, _ = anypb.New(&pb.SimpleMessage{
|
||||
message, _ = anypb.New(&pb.SingleMessage{
|
||||
Message: data,
|
||||
})
|
||||
|
||||
case []container.LogFragment:
|
||||
fragments := make([]*pb.LogFragment, len(data))
|
||||
for i, f := range data {
|
||||
fragments[i] = &pb.LogFragment{
|
||||
Message: f.Message,
|
||||
}
|
||||
}
|
||||
message, _ = anypb.New(&pb.GroupMessage{
|
||||
Fragments: fragments,
|
||||
})
|
||||
|
||||
case *orderedmap.OrderedMap[string, any]:
|
||||
message, _ = anypb.New(&pb.ComplexMessage{
|
||||
Data: orderedMapToJSONBytes(data),
|
||||
@@ -389,7 +472,7 @@ func logEventToPb(event *container.LogEvent) *pb.LogEvent {
|
||||
ContainerId: event.ContainerID,
|
||||
Level: event.Level,
|
||||
Stream: event.Stream,
|
||||
Position: string(event.Position),
|
||||
Type: string(event.Type),
|
||||
RawMessage: string(event.RawMessage),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ func (s StdType) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
type ExecSession struct {
|
||||
Writer io.WriteCloser
|
||||
Reader io.Reader
|
||||
Resize func(width uint, height uint) error
|
||||
}
|
||||
|
||||
type ExecEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Width uint `json:"width,omitempty"`
|
||||
Height uint `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
ListContainers(context.Context, ContainerLabels) ([]Container, error)
|
||||
FindContainer(context.Context, string) (Container, error)
|
||||
@@ -38,6 +51,6 @@ type Client interface {
|
||||
Ping(context.Context) error
|
||||
Host() Host
|
||||
ContainerActions(ctx context.Context, action ContainerAction, containerID string) error
|
||||
ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error)
|
||||
ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error)
|
||||
ContainerAttach(ctx context.Context, id string) (*ExecSession, error)
|
||||
ContainerExec(ctx context.Context, id string, cmd []string) (*ExecSession, error)
|
||||
}
|
||||
|
||||
@@ -48,38 +48,140 @@ func NewEventGenerator(ctx context.Context, reader LogReader, container Containe
|
||||
return generator
|
||||
}
|
||||
|
||||
func (g *EventGenerator) emit(event *LogEvent) bool {
|
||||
select {
|
||||
case g.Events <- event:
|
||||
return true
|
||||
case <-g.ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *EventGenerator) flushGroup(pendingGroup []*LogEvent) bool {
|
||||
if len(pendingGroup) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(pendingGroup) == 1 {
|
||||
pendingGroup[0].Type = LogTypeSingle
|
||||
return g.emit(pendingGroup[0])
|
||||
}
|
||||
|
||||
first := pendingGroup[0]
|
||||
fragments := make([]LogFragment, len(pendingGroup))
|
||||
for i, e := range pendingGroup {
|
||||
fragments[i] = LogFragment{Message: e.Message.(string)}
|
||||
}
|
||||
|
||||
return g.emit(&LogEvent{
|
||||
Type: LogTypeGroup,
|
||||
Message: fragments,
|
||||
Timestamp: first.Timestamp,
|
||||
Id: first.Id,
|
||||
Level: first.Level,
|
||||
Stream: first.Stream,
|
||||
ContainerID: first.ContainerID,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *EventGenerator) processBuffer() {
|
||||
var current, next *LogEvent
|
||||
var pendingGroup []*LogEvent
|
||||
|
||||
loop:
|
||||
for {
|
||||
if g.next != nil {
|
||||
current = g.next
|
||||
g.next = nil
|
||||
next = g.peek()
|
||||
} else {
|
||||
event, ok := <-g.buffer
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
current = event
|
||||
next = g.peek()
|
||||
current := g.nextEvent()
|
||||
if current == nil {
|
||||
g.flushGroup(pendingGroup)
|
||||
break loop
|
||||
}
|
||||
|
||||
checkPosition(current, next)
|
||||
// Complex logs are emitted immediately
|
||||
if !current.IsSimple() {
|
||||
if !g.flushGroup(pendingGroup) {
|
||||
break loop
|
||||
}
|
||||
pendingGroup = nil
|
||||
if !g.emit(current) {
|
||||
break loop
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case g.Events <- current:
|
||||
case <-g.ctx.Done():
|
||||
break loop
|
||||
// Simple log - peek ahead to decide grouping
|
||||
next := g.peek()
|
||||
|
||||
if len(pendingGroup) == 0 {
|
||||
if next != nil && next.IsSimple() && canStartGroup(current, next) {
|
||||
next.Level = current.Level
|
||||
pendingGroup = append(pendingGroup, current)
|
||||
} else {
|
||||
current.Type = LogTypeSingle
|
||||
if !g.emit(current) {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pendingGroup = append(pendingGroup, current)
|
||||
|
||||
if next == nil || !next.IsSimple() || !canContinueGroup(pendingGroup[0], next) {
|
||||
if !g.flushGroup(pendingGroup) {
|
||||
break loop
|
||||
}
|
||||
pendingGroup = nil
|
||||
} else {
|
||||
next.Level = pendingGroup[0].Level
|
||||
}
|
||||
}
|
||||
|
||||
close(g.Events)
|
||||
|
||||
g.wg.Done()
|
||||
}
|
||||
|
||||
func (g *EventGenerator) nextEvent() *LogEvent {
|
||||
if g.next != nil {
|
||||
event := g.next
|
||||
g.next = nil
|
||||
return event
|
||||
}
|
||||
event, ok := <-g.buffer
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
// canStartGroup checks if current can start a group with next
|
||||
func canStartGroup(current, next *LogEvent) bool {
|
||||
// Current must have a known level
|
||||
if !current.HasLevel() {
|
||||
return false
|
||||
}
|
||||
// Next must not have its own level (continuation)
|
||||
if next.HasLevel() {
|
||||
return false
|
||||
}
|
||||
// Must be close in time
|
||||
if !current.IsCloseToTime(next) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// canContinueGroup checks if next can be added to a group started by first
|
||||
func canContinueGroup(first, next *LogEvent) bool {
|
||||
// Next must not have its own level (continuation)
|
||||
if next.HasLevel() {
|
||||
return false
|
||||
}
|
||||
// Must be close in time to the group leader
|
||||
if !first.IsCloseToTime(next) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *EventGenerator) consumeReader() {
|
||||
for {
|
||||
message, streamType, readerError := g.reader.Read()
|
||||
@@ -117,7 +219,7 @@ func (g *EventGenerator) peek() *LogEvent {
|
||||
func createEvent(message string, streamType StdType) *LogEvent {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(message))
|
||||
logEvent := &LogEvent{Id: h.Sum32(), Message: message, Stream: streamType.String()}
|
||||
logEvent := &LogEvent{Id: h.Sum32(), Message: message, Stream: streamType.String(), Type: LogTypeSingle}
|
||||
if index := strings.IndexAny(message, " "); index != -1 {
|
||||
logId := message[:index]
|
||||
if timestamp, err := time.Parse(time.RFC3339Nano, logId); err == nil {
|
||||
@@ -141,10 +243,12 @@ func createEvent(message string, streamType StdType) *LogEvent {
|
||||
logEvent.Message = ""
|
||||
} else {
|
||||
logEvent.Message = data
|
||||
logEvent.Type = LogTypeComplex
|
||||
}
|
||||
}
|
||||
} else if data, err := ParseLogFmt(message); err == nil {
|
||||
logEvent.Message = data
|
||||
logEvent.Type = LogTypeComplex
|
||||
data, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to marshal json")
|
||||
@@ -155,29 +259,3 @@ func createEvent(message string, streamType StdType) *LogEvent {
|
||||
}
|
||||
return logEvent
|
||||
}
|
||||
|
||||
func checkPosition(currentEvent *LogEvent, nextEvent *LogEvent) {
|
||||
currentLevel := guessLogLevel(currentEvent)
|
||||
if nextEvent != nil {
|
||||
if currentEvent.IsCloseToTime(nextEvent) && currentLevel != "unknown" && !nextEvent.HasLevel() {
|
||||
currentEvent.Position = Beginning
|
||||
nextEvent.Position = Middle
|
||||
}
|
||||
|
||||
// If next item is not close to current item or has level, set current item position to end
|
||||
if currentEvent.Position == Middle && (nextEvent.HasLevel() || !currentEvent.IsCloseToTime(nextEvent)) {
|
||||
currentEvent.Position = End
|
||||
}
|
||||
|
||||
// If next item is close to current item and has no level, set next item position to middle
|
||||
if currentEvent.Position == Middle && !nextEvent.HasLevel() && currentEvent.IsCloseToTime(nextEvent) {
|
||||
nextEvent.Position = Middle
|
||||
}
|
||||
// Set next item level to current item level
|
||||
if currentEvent.Position == Beginning || currentEvent.Position == Middle {
|
||||
nextEvent.Level = currentEvent.Level
|
||||
}
|
||||
} else if currentEvent.Position == Middle {
|
||||
currentEvent.Position = End
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestEventGenerator_Events_tty(t *testing.T) {
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil, but got nil")
|
||||
assert.Equal(t, input, event.Message)
|
||||
assert.Equal(t, LogTypeSingle, event.Type)
|
||||
}
|
||||
|
||||
func TestEventGenerator_Events_non_tty(t *testing.T) {
|
||||
@@ -31,6 +32,7 @@ func TestEventGenerator_Events_non_tty(t *testing.T) {
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil, but got nil")
|
||||
assert.Equal(t, input, event.Message)
|
||||
assert.Equal(t, LogTypeSingle, event.Type)
|
||||
}
|
||||
|
||||
func TestEventGenerator_Events_non_tty_close_channel(t *testing.T) {
|
||||
@@ -161,3 +163,103 @@ func Test_createEvent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventGenerator_ComplexLog(t *testing.T) {
|
||||
input := "2020-05-13T18:55:37.772853839Z {\"level\": \"info\", \"message\": \"test\"}"
|
||||
|
||||
g := NewEventGenerator(context.Background(), makeFakeReader(input, STDOUT), Container{Tty: false})
|
||||
event := <-g.Events
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil")
|
||||
assert.Equal(t, LogTypeComplex, event.Type)
|
||||
_, isMap := event.Message.(*orderedmap.OrderedMap[string, any])
|
||||
assert.True(t, isMap, "Expected Message to be an ordered map")
|
||||
}
|
||||
|
||||
func TestEventGenerator_GroupedSimpleLogs(t *testing.T) {
|
||||
// Create messages with same timestamp (close enough to group) where first has level
|
||||
baseTime := "2020-05-13T18:55:37.772853839Z"
|
||||
messages := []string{
|
||||
baseTime + " ERROR: Something went wrong",
|
||||
baseTime + " at line 42",
|
||||
baseTime + " in function foo",
|
||||
}
|
||||
|
||||
reader := &mockLogReader{
|
||||
messages: messages,
|
||||
types: []StdType{STDERR, STDERR, STDERR},
|
||||
}
|
||||
|
||||
g := NewEventGenerator(context.Background(), reader, Container{Tty: false})
|
||||
event := <-g.Events
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil")
|
||||
assert.Equal(t, LogTypeGroup, event.Type)
|
||||
|
||||
fragments, ok := event.Message.([]LogFragment)
|
||||
require.True(t, ok, "Expected Message to be []LogFragment")
|
||||
assert.Len(t, fragments, 3)
|
||||
assert.Equal(t, "ERROR: Something went wrong", fragments[0].Message)
|
||||
assert.Equal(t, "at line 42", fragments[1].Message)
|
||||
assert.Equal(t, "in function foo", fragments[2].Message)
|
||||
}
|
||||
|
||||
func TestEventGenerator_SingleSimpleLog(t *testing.T) {
|
||||
input := "2020-05-13T18:55:37.772853839Z INFO: Single log message"
|
||||
|
||||
g := NewEventGenerator(context.Background(), makeFakeReader(input, STDOUT), Container{Tty: false})
|
||||
event := <-g.Events
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil")
|
||||
assert.Equal(t, LogTypeSingle, event.Type)
|
||||
assert.Equal(t, "INFO: Single log message", event.Message)
|
||||
}
|
||||
|
||||
func TestEventGenerator_MixedLogs(t *testing.T) {
|
||||
// Mix of complex and simple logs
|
||||
messages := []string{
|
||||
"2020-05-13T18:55:37.772853839Z {\"level\": \"info\"}",
|
||||
"2020-05-13T18:55:38.772853839Z WARN: warning message",
|
||||
}
|
||||
|
||||
reader := &mockLogReader{
|
||||
messages: messages,
|
||||
types: []StdType{STDOUT, STDOUT},
|
||||
}
|
||||
|
||||
g := NewEventGenerator(context.Background(), reader, Container{Tty: false})
|
||||
|
||||
// First event should be complex
|
||||
event1 := <-g.Events
|
||||
require.NotNil(t, event1)
|
||||
assert.Equal(t, LogTypeComplex, event1.Type)
|
||||
|
||||
// Second event should be single simple
|
||||
event2 := <-g.Events
|
||||
require.NotNil(t, event2)
|
||||
assert.Equal(t, LogTypeSingle, event2.Type)
|
||||
}
|
||||
|
||||
func TestEventGenerator_NoGroupingWhenTimestampGap(t *testing.T) {
|
||||
// Messages with different timestamps (too far apart to group)
|
||||
messages := []string{
|
||||
"2020-05-13T18:55:37.000Z ERROR: First error",
|
||||
"2020-05-13T18:55:38.000Z continuation line",
|
||||
}
|
||||
|
||||
reader := &mockLogReader{
|
||||
messages: messages,
|
||||
types: []StdType{STDERR, STDERR},
|
||||
}
|
||||
|
||||
g := NewEventGenerator(context.Background(), reader, Container{Tty: false})
|
||||
|
||||
// Should get two separate events (not grouped due to timestamp gap)
|
||||
event1 := <-g.Events
|
||||
require.NotNil(t, event1)
|
||||
assert.Equal(t, LogTypeSingle, event1.Type)
|
||||
|
||||
event2 := <-g.Events
|
||||
require.NotNil(t, event2)
|
||||
assert.Equal(t, LogTypeSingle, event2.Type)
|
||||
}
|
||||
|
||||
@@ -64,13 +64,17 @@ func guessLogLevel(logEvent *LogEvent) string {
|
||||
}
|
||||
|
||||
// Look for the level in the middle of the message that are uppercase and surrounded by quotes
|
||||
if strings.Contains(value, "\""+strings.ToUpper(first)+"\"") {
|
||||
return first
|
||||
for _, level := range levelGroup {
|
||||
if strings.Contains(value, "\""+strings.ToUpper(level)+"\"") {
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
// Look for the level in the middle of the message that are uppercase
|
||||
if strings.Contains(value, " "+strings.ToUpper(first)+" ") {
|
||||
return first
|
||||
for _, level := range levelGroup {
|
||||
if strings.Contains(value, " "+strings.ToUpper(level)+" ") {
|
||||
return first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ func TestGuessLogLevel(t *testing.T) {
|
||||
{"inf Something went wrong", "info"},
|
||||
{"crit: Something went wrong", "fatal"},
|
||||
{"[21:01:45] [WRN] this is a test", "warn"},
|
||||
{"2026-01-05 12:13:24,566 - retry.api (7fd8ad34eb30) : WARNING (api:40) - HTTPSConnectionPool(host='podnapisi.net', port=443): Max retries exceeded", "warn"},
|
||||
{"2026-01-05 08:21:16,511 - root (7fd8bf822b30) : INFO (get_providers:408) - Throttling podnapisi for 10 minutes", "info"},
|
||||
{orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key", Value: "value"},
|
||||
|
||||
@@ -98,10 +98,12 @@ func FromProto(c *pb.Container) Container {
|
||||
|
||||
// ContainerStat represent stats instant for a container
|
||||
type ContainerStat struct {
|
||||
ID string `json:"id"`
|
||||
CPUPercent float64 `json:"cpu"`
|
||||
MemoryPercent float64 `json:"memory"`
|
||||
MemoryUsage float64 `json:"memoryUsage"`
|
||||
ID string `json:"id"`
|
||||
CPUPercent float64 `json:"cpu"`
|
||||
MemoryPercent float64 `json:"memory"`
|
||||
MemoryUsage float64 `json:"memoryUsage"`
|
||||
NetworkRxTotal uint64 `json:"networkRxTotal"`
|
||||
NetworkTxTotal uint64 `json:"networkTxTotal"`
|
||||
}
|
||||
|
||||
// ContainerEvent represents events that are triggered
|
||||
@@ -147,6 +149,19 @@ const (
|
||||
End LogPosition = "end"
|
||||
)
|
||||
|
||||
type LogType string
|
||||
|
||||
const (
|
||||
LogTypeSingle LogType = "single" // Single simple text log (no grouping)
|
||||
LogTypeGroup LogType = "group" // Grouped simple logs (array of fragments)
|
||||
LogTypeComplex LogType = "complex" // JSON or logfmt parsed log
|
||||
)
|
||||
|
||||
// LogFragment represents a single line within a grouped simple log
|
||||
type LogFragment struct {
|
||||
Message string `json:"m"`
|
||||
}
|
||||
|
||||
type ContainerAction string
|
||||
|
||||
const (
|
||||
@@ -166,20 +181,24 @@ func ParseContainerAction(input string) (ContainerAction, error) {
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
Message any `json:"m,omitempty"`
|
||||
RawMessage string `json:"rm,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Id uint32 `json:"id,omitempty"`
|
||||
Level string `json:"l,omitempty"`
|
||||
Position LogPosition `json:"p,omitempty"`
|
||||
Stream string `json:"s,omitempty"`
|
||||
ContainerID string `json:"c,omitempty"`
|
||||
Type LogType `json:"t,omitempty"`
|
||||
Message any `json:"m,omitempty"`
|
||||
RawMessage string `json:"rm,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Id uint32 `json:"id,omitempty"`
|
||||
Level string `json:"l,omitempty"`
|
||||
Stream string `json:"s,omitempty"`
|
||||
ContainerID string `json:"c,omitempty"`
|
||||
}
|
||||
|
||||
func (l *LogEvent) HasLevel() bool {
|
||||
return l.Level != "unknown"
|
||||
}
|
||||
|
||||
func (l *LogEvent) IsSimple() bool {
|
||||
return l.Type == LogTypeSingle || l.Type == LogTypeGroup
|
||||
}
|
||||
|
||||
func (l *LogEvent) IsCloseToTime(other *LogEvent) bool {
|
||||
return math.Abs(float64(l.Timestamp-other.Timestamp)) < 10
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ func (d *DockerClient) ContainerStats(ctx context.Context, id string, stats chan
|
||||
mem, memLimit float64
|
||||
previousCPU uint64
|
||||
previousSystem uint64
|
||||
networkRx, networkTx uint64
|
||||
)
|
||||
daemonOSType := response.OSType
|
||||
|
||||
@@ -213,15 +214,23 @@ func (d *DockerClient) ContainerStats(ctx context.Context, id string, stats chan
|
||||
mem = float64(v.MemoryStats.PrivateWorkingSet)
|
||||
}
|
||||
|
||||
// Calculate total network bytes across all interfaces
|
||||
for _, netStats := range v.Networks {
|
||||
networkRx += netStats.RxBytes
|
||||
networkTx += netStats.TxBytes
|
||||
}
|
||||
|
||||
if cpuPercent > 0 || mem > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case stats <- container.ContainerStat{
|
||||
ID: id,
|
||||
CPUPercent: cpuPercent,
|
||||
MemoryPercent: memPercent,
|
||||
MemoryUsage: mem,
|
||||
ID: id,
|
||||
CPUPercent: cpuPercent,
|
||||
MemoryPercent: memPercent,
|
||||
MemoryUsage: mem,
|
||||
NetworkRxTotal: networkRx,
|
||||
NetworkTxTotal: networkTx,
|
||||
}:
|
||||
}
|
||||
}
|
||||
@@ -301,7 +310,7 @@ func (d *DockerClient) Host() container.Host {
|
||||
return d.host
|
||||
}
|
||||
|
||||
func (d *DockerClient) ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error) {
|
||||
func (d *DockerClient) ContainerAttach(ctx context.Context, id string) (*container.ExecSession, error) {
|
||||
log.Debug().Str("id", id).Str("host", d.host.Name).Msg("Attaching to container")
|
||||
options := docker.AttachOptions{
|
||||
Stream: true,
|
||||
@@ -313,13 +322,24 @@ func (d *DockerClient) ContainerAttach(ctx context.Context, id string) (io.Write
|
||||
waiter, err := d.cli.ContainerAttach(ctx, id, options)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return waiter.Conn, waiter.Reader, nil
|
||||
// Docker attach doesn't support resize - it's not an exec session
|
||||
// Return a no-op resize function
|
||||
resizeFn := func(width uint, height uint) error {
|
||||
log.Debug().Uint("width", width).Uint("height", height).Msg("resize not supported for attach")
|
||||
return nil
|
||||
}
|
||||
|
||||
return &container.ExecSession{
|
||||
Writer: waiter.Conn,
|
||||
Reader: waiter.Reader,
|
||||
Resize: resizeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DockerClient) ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error) {
|
||||
func (d *DockerClient) ContainerExec(ctx context.Context, id string, cmd []string) (*container.ExecSession, error) {
|
||||
log.Debug().Str("id", id).Str("host", d.host.Name).Msg("Executing command in container")
|
||||
options := docker.ExecOptions{
|
||||
AttachStdout: true,
|
||||
@@ -331,22 +351,35 @@ func (d *DockerClient) ContainerExec(ctx context.Context, id string, cmd []strin
|
||||
|
||||
execID, err := d.cli.ContainerExecCreate(ctx, id, options)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
waiter, err := d.cli.ContainerExecAttach(ctx, execID.ID, docker.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initial resize
|
||||
if err = d.cli.ContainerExecResize(ctx, execID.ID, docker.ResizeOptions{
|
||||
Width: 100,
|
||||
Height: 40,
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return waiter.Conn, waiter.Reader, nil
|
||||
// Create resize closure that captures execID and context
|
||||
resizeFn := func(width uint, height uint) error {
|
||||
return d.cli.ContainerExecResize(ctx, execID.ID, docker.ResizeOptions{
|
||||
Width: width,
|
||||
Height: height,
|
||||
})
|
||||
}
|
||||
|
||||
return &container.ExecSession{
|
||||
Writer: waiter.Conn,
|
||||
Reader: waiter.Reader,
|
||||
Resize: resizeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newContainer(c docker.Summary, host string) container.Container {
|
||||
|
||||
@@ -94,6 +94,21 @@ func podToContainers(pod *corev1.Pod) []container.Container {
|
||||
if pod.Status.StartTime != nil {
|
||||
started = pod.Status.StartTime.Time
|
||||
}
|
||||
|
||||
// Build labels map with pod labels, namespace, and owner reference
|
||||
labels := make(map[string]string)
|
||||
for k, v := range pod.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
labels["namespace"] = pod.Namespace
|
||||
|
||||
// Add owner reference if present
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
owner := pod.OwnerReferences[0]
|
||||
labels["owner.kind"] = owner.Kind
|
||||
labels["owner.name"] = owner.Name
|
||||
}
|
||||
|
||||
var containers []container.Container
|
||||
for _, c := range pod.Spec.Containers {
|
||||
containers = append(containers, container.Container{
|
||||
@@ -106,6 +121,7 @@ func podToContainers(pod *corev1.Pod) []container.Container {
|
||||
Command: strings.Join(c.Command, " "),
|
||||
Host: pod.Spec.NodeName,
|
||||
Tty: c.TTY,
|
||||
Labels: labels,
|
||||
Stats: utils.NewRingBuffer[container.ContainerStat](300),
|
||||
FullyLoaded: true,
|
||||
})
|
||||
@@ -277,7 +293,8 @@ func (k *K8sClient) ContainerEvents(ctx context.Context, ch chan<- container.Con
|
||||
}
|
||||
|
||||
func (k *K8sClient) ContainerStats(ctx context.Context, id string, stats chan<- container.ContainerStat) error {
|
||||
panic("not implemented")
|
||||
// Stats collection is implemented in stats_collector.go using K8s metrics API
|
||||
panic("not implemented - use K8sStatsCollector instead")
|
||||
}
|
||||
|
||||
func (k *K8sClient) Ping(ctx context.Context) error {
|
||||
@@ -293,9 +310,9 @@ func (k *K8sClient) ContainerActions(ctx context.Context, action container.Conta
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (k *K8sClient) ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error) {
|
||||
func (k *K8sClient) ContainerAttach(ctx context.Context, id string) (*container.ExecSession, error) {
|
||||
namespace, podName, containerName := parsePodContainerID(id)
|
||||
log.Debug().Str("container", containerName).Str("pod", podName).Msg("Executing command in pod")
|
||||
log.Debug().Str("container", containerName).Str("pod", podName).Msg("Attaching to pod")
|
||||
req := k.Clientset.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Name(podName).
|
||||
@@ -317,27 +334,60 @@ func (k *K8sClient) ContainerAttach(ctx context.Context, id string) (io.WriteClo
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(k.config, "POST", req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stdinReader, stdinWriter := io.Pipe()
|
||||
stdoutReader, stdoutWriter := io.Pipe()
|
||||
|
||||
// Create TerminalSizeQueue for dynamic resizing
|
||||
sizeQueue := &terminalSizeQueue{
|
||||
resizeChan: make(chan remotecommand.TerminalSize, 1),
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
||||
Stdin: stdinReader,
|
||||
Stdout: stdoutWriter,
|
||||
Tty: true,
|
||||
Stdin: stdinReader,
|
||||
Stdout: stdoutWriter,
|
||||
Tty: true,
|
||||
TerminalSizeQueue: sizeQueue,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error streaming command")
|
||||
}
|
||||
}()
|
||||
|
||||
return stdinWriter, stdoutReader, nil
|
||||
// Create resize closure that sends to the queue
|
||||
resizeFn := func(width uint, height uint) error {
|
||||
select {
|
||||
case sizeQueue.resizeChan <- remotecommand.TerminalSize{Width: uint16(width), Height: uint16(height)}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("resize queue full")
|
||||
}
|
||||
}
|
||||
|
||||
return &container.ExecSession{
|
||||
Writer: stdinWriter,
|
||||
Reader: stdoutReader,
|
||||
Resize: resizeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (k *K8sClient) ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error) {
|
||||
// terminalSizeQueue implements remotecommand.TerminalSizeQueue
|
||||
type terminalSizeQueue struct {
|
||||
resizeChan chan remotecommand.TerminalSize
|
||||
}
|
||||
|
||||
func (t *terminalSizeQueue) Next() *remotecommand.TerminalSize {
|
||||
size, ok := <-t.resizeChan
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &size
|
||||
}
|
||||
|
||||
func (k *K8sClient) ContainerExec(ctx context.Context, id string, cmd []string) (*container.ExecSession, error) {
|
||||
namespace, podName, containerName := parsePodContainerID(id)
|
||||
log.Debug().Str("container", containerName).Str("pod", podName).Msg("Executing command in pod")
|
||||
req := k.Clientset.CoreV1().RESTClient().Post().
|
||||
@@ -362,24 +412,44 @@ func (k *K8sClient) ContainerExec(ctx context.Context, id string, cmd []string)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(k.config, "POST", req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stdinReader, stdinWriter := io.Pipe()
|
||||
stdoutReader, stdoutWriter := io.Pipe()
|
||||
|
||||
// Create TerminalSizeQueue for dynamic resizing
|
||||
sizeQueue := &terminalSizeQueue{
|
||||
resizeChan: make(chan remotecommand.TerminalSize, 1),
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
||||
Stdin: stdinReader,
|
||||
Stdout: stdoutWriter,
|
||||
Tty: true,
|
||||
Stdin: stdinReader,
|
||||
Stdout: stdoutWriter,
|
||||
Tty: true,
|
||||
TerminalSizeQueue: sizeQueue,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error streaming command")
|
||||
}
|
||||
}()
|
||||
|
||||
return stdinWriter, stdoutReader, nil
|
||||
// Create resize closure that sends to the queue
|
||||
resizeFn := func(width uint, height uint) error {
|
||||
select {
|
||||
case sizeQueue.resizeChan <- remotecommand.TerminalSize{Width: uint16(width), Height: uint16(height)}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("resize queue full")
|
||||
}
|
||||
}
|
||||
|
||||
return &container.ExecSession{
|
||||
Writer: stdinWriter,
|
||||
Reader: stdoutReader,
|
||||
Resize: resizeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper function to parse pod and container names from container ID
|
||||
|
||||
@@ -105,9 +105,11 @@ func (sc *K8sStatsCollector) Start(parentCtx context.Context) bool {
|
||||
for _, pod := range metricList.Items {
|
||||
for _, c := range pod.Containers {
|
||||
stat := container.ContainerStat{
|
||||
ID: pod.Namespace + ":" + pod.Name + ":" + c.Name,
|
||||
CPUPercent: float64(c.Usage.Cpu().MilliValue()) / 1000 * 100,
|
||||
MemoryUsage: c.Usage.Memory().AsApproximateFloat64(),
|
||||
ID: pod.Namespace + ":" + pod.Name + ":" + c.Name,
|
||||
CPUPercent: float64(c.Usage.Cpu().MilliValue()) / 1000 * 100,
|
||||
MemoryUsage: c.Usage.Memory().AsApproximateFloat64(),
|
||||
NetworkRxTotal: 0, // K8s metrics API doesn't expose network stats by default
|
||||
NetworkTxTotal: 0, // Would require custom metrics or cAdvisor integration
|
||||
}
|
||||
log.Trace().Interface("stat", stat).Msg("k8s stats")
|
||||
sc.subscribers.Range(func(c context.Context, stats chan<- container.ContainerStat) bool {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/vm"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Manager handles notification subscriptions and matching
|
||||
type Manager struct {
|
||||
subscriptions []*compiledSubscription
|
||||
mu sync.RWMutex
|
||||
activeStreams map[string]*activeStream // containerID -> stream info
|
||||
containerCache map[string]Container // containerID -> notification.Container
|
||||
containerService ContainerService
|
||||
dispatcher Dispatcher
|
||||
}
|
||||
|
||||
// activeStream tracks a single log stream and all subscriptions watching it
|
||||
type activeStream struct {
|
||||
container Container // notification.Container
|
||||
subscriptions []*compiledSubscription // All subscriptions watching this container
|
||||
cancel context.CancelFunc // Cancel function for the stream
|
||||
}
|
||||
|
||||
// compiledSubscription wraps a Subscription with compiled expr programs
|
||||
type compiledSubscription struct {
|
||||
subscription *Subscription
|
||||
containerProgram *vm.Program // Always non-nil
|
||||
logProgram *vm.Program // Always non-nil
|
||||
}
|
||||
|
||||
// ContainerService provides access to containers and log streaming
|
||||
type ContainerService interface {
|
||||
ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error)
|
||||
FindContainer(host, id string, labels container.ContainerLabels) (*container_support.ContainerService, error)
|
||||
SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter)
|
||||
}
|
||||
|
||||
// NewManager creates a new notification manager
|
||||
func NewManager(containerService ContainerService, dispatcher Dispatcher) *Manager {
|
||||
return &Manager{
|
||||
subscriptions: make([]*compiledSubscription, 0),
|
||||
activeStreams: make(map[string]*activeStream),
|
||||
containerCache: make(map[string]Container),
|
||||
containerService: containerService,
|
||||
dispatcher: dispatcher,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadSubscriptions loads and compiles multiple subscriptions (replaces existing)
|
||||
func (m *Manager) LoadSubscriptions(subs []*Subscription) error {
|
||||
m.mu.Lock()
|
||||
m.subscriptions = make([]*compiledSubscription, 0, len(subs))
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, sub := range subs {
|
||||
if err := m.AddSubscription(sub); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(subs)).Msg("loaded notification subscriptions")
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSubscription adds a single subscription
|
||||
func (m *Manager) AddSubscription(sub *Subscription) error {
|
||||
cs, err := m.compileSubscription(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.subscriptions = append(m.subscriptions, cs)
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Info().Str("subscription", sub.Name).Msg("added notification subscription")
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileSubscription compiles expr programs for a subscription
|
||||
func (m *Manager) compileSubscription(sub *Subscription) (*compiledSubscription, error) {
|
||||
if sub.ContainerFilter == "" {
|
||||
return nil, fmt.Errorf("container_filter is required for subscription %q", sub.Name)
|
||||
}
|
||||
if sub.LogFilter == "" {
|
||||
return nil, fmt.Errorf("log_filter is required for subscription %q", sub.Name)
|
||||
}
|
||||
|
||||
cs := &compiledSubscription{
|
||||
subscription: sub,
|
||||
}
|
||||
|
||||
// Compile container filter (required)
|
||||
containerProgram, err := expr.Compile(sub.ContainerFilter, expr.Env(Container{}))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("subscription", sub.Name).Msg("failed to compile container filter")
|
||||
return nil, fmt.Errorf("failed to compile container_filter for %q: %w", sub.Name, err)
|
||||
}
|
||||
cs.containerProgram = containerProgram
|
||||
|
||||
// Compile log filter (required)
|
||||
logProgram, err := expr.Compile(sub.LogFilter, expr.Env(Log{}))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("subscription", sub.Name).Msg("failed to compile log filter")
|
||||
return nil, fmt.Errorf("failed to compile log_filter for %q: %w", sub.Name, err)
|
||||
}
|
||||
cs.logProgram = logProgram
|
||||
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// Start begins monitoring containers and streaming logs for matching subscriptions
|
||||
// Only starts if there are subscriptions configured
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
hasSubscriptions := len(m.subscriptions) > 0
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !hasSubscriptions {
|
||||
log.Debug().Msg("no subscriptions configured, skipping notification manager start")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe to new containers that match our filters
|
||||
newContainers := make(chan container.Container)
|
||||
m.containerService.SubscribeContainersStarted(ctx, newContainers, func(c *container.Container) bool {
|
||||
matchingSubs := m.getMatchingSubscriptions(c)
|
||||
return len(matchingSubs) > 0
|
||||
})
|
||||
|
||||
// Handle new containers in background
|
||||
go func() {
|
||||
for c := range newContainers {
|
||||
m.startContainerStream(ctx, &c)
|
||||
}
|
||||
}()
|
||||
|
||||
// Get all existing containers and start streaming matching ones
|
||||
containers, errs := m.containerService.ListAllContainers(nil)
|
||||
for _, err := range errs {
|
||||
log.Warn().Err(err).Msg("error listing containers for notifications")
|
||||
}
|
||||
|
||||
// Check each container and start streaming only if it matches any subscription
|
||||
for _, c := range containers {
|
||||
if c.State == "running" {
|
||||
matchingSubs := m.getMatchingSubscriptions(&c)
|
||||
if len(matchingSubs) > 0 {
|
||||
m.startContainerStream(ctx, &c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startContainerStream starts a single log stream for a container
|
||||
// All matching subscriptions will receive logs from this one stream
|
||||
func (m *Manager) startContainerStream(ctx context.Context, c *container.Container) {
|
||||
// Check if already streaming
|
||||
m.mu.RLock()
|
||||
_, exists := m.activeStreams[c.ID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Find which subscriptions match this container
|
||||
matchingSubs := m.getMatchingSubscriptions(c)
|
||||
if len(matchingSubs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("container", c.Name).
|
||||
Int("subscriptions", len(matchingSubs)).
|
||||
Msg("starting log stream for notification subscriptions")
|
||||
|
||||
// Convert to notification.Container and cache it
|
||||
notifContainer := NewContainer(c)
|
||||
m.mu.Lock()
|
||||
m.containerCache[c.ID] = notifContainer
|
||||
m.mu.Unlock()
|
||||
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
stream := &activeStream{
|
||||
container: notifContainer,
|
||||
subscriptions: matchingSubs,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.activeStreams[c.ID] = stream
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
delete(m.activeStreams, c.ID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
logs := make(chan *container.LogEvent)
|
||||
|
||||
// Stream logs in separate goroutine
|
||||
go func() {
|
||||
containerService, err := m.containerService.FindContainer(c.Host, c.ID, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error finding container for notification streaming")
|
||||
return
|
||||
}
|
||||
|
||||
err = containerService.StreamLogs(streamCtx, time.Now(), container.STDOUT|container.STDERR, logs)
|
||||
if err != nil && err != context.Canceled {
|
||||
log.Error().Err(err).Msg("error streaming logs for notification")
|
||||
}
|
||||
close(logs)
|
||||
}()
|
||||
|
||||
// Process logs - check against all matching subscriptions
|
||||
for logEvent := range logs {
|
||||
for _, cs := range matchingSubs {
|
||||
m.processLogEvent(logEvent, c.ID, cs)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// getMatchingSubscriptions returns subscriptions that match a container
|
||||
func (m *Manager) getMatchingSubscriptions(c *container.Container) []*compiledSubscription {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
containerCtx := NewContainer(c)
|
||||
matching := make([]*compiledSubscription, 0)
|
||||
|
||||
for _, cs := range m.subscriptions {
|
||||
if m.matchesContainer(cs.containerProgram, containerCtx) {
|
||||
matching = append(matching, cs)
|
||||
}
|
||||
}
|
||||
|
||||
return matching
|
||||
}
|
||||
|
||||
// matchesContainer evaluates the container filter expression
|
||||
func (m *Manager) matchesContainer(program *vm.Program, containerCtx Container) bool {
|
||||
result, err := expr.Run(program, containerCtx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error evaluating container filter")
|
||||
return false
|
||||
}
|
||||
|
||||
match, ok := result.(bool)
|
||||
if !ok {
|
||||
log.Error().Msg("container filter did not return boolean")
|
||||
return false
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
|
||||
// processLogEvent evaluates a log event against the subscription's log filter
|
||||
func (m *Manager) processLogEvent(logEvent *container.LogEvent, containerID string, cs *compiledSubscription) {
|
||||
// Lookup notification.Container from cache
|
||||
m.mu.RLock()
|
||||
notifContainer, ok := m.containerCache[containerID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
log.Warn().Str("container", containerID).Msg("container not in cache")
|
||||
return
|
||||
}
|
||||
|
||||
logCtx := NewLog(logEvent, notifContainer)
|
||||
|
||||
// Evaluate log filter (program is never nil)
|
||||
if m.matchesLog(cs.logProgram, logCtx) {
|
||||
m.triggerWebhook(cs.subscription, logCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// matchesLog evaluates the log filter expression
|
||||
func (m *Manager) matchesLog(program *vm.Program, logCtx Log) bool {
|
||||
result, err := expr.Run(program, logCtx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error evaluating log filter")
|
||||
return false
|
||||
}
|
||||
|
||||
match, ok := result.(bool)
|
||||
if !ok {
|
||||
log.Error().Msg("log filter did not return boolean")
|
||||
return false
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
|
||||
// triggerWebhook sends the notification
|
||||
func (m *Manager) triggerWebhook(sub *Subscription, logCtx Log) {
|
||||
payload := WebhookPayload{
|
||||
SubscriptionName: sub.Name,
|
||||
Timestamp: time.Now(),
|
||||
Container: logCtx.Container,
|
||||
Log: LogEvent{
|
||||
Message: logCtx.Message,
|
||||
Level: logCtx.Level,
|
||||
Timestamp: logCtx.Timestamp,
|
||||
},
|
||||
}
|
||||
|
||||
m.dispatcher.Dispatch(sub, payload)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the YAML configuration structure
|
||||
type Config struct {
|
||||
Dispatcher DispatcherConfig `yaml:"dispatcher"`
|
||||
Subscriptions []*Subscription `yaml:"subscriptions"`
|
||||
}
|
||||
|
||||
// LoadFromFile loads config and returns a fully configured Manager
|
||||
func LoadFromFile(path string, containerService ContainerService) (*Manager, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No config file is fine, return nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read notifications config: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse notifications config: %w", err)
|
||||
}
|
||||
|
||||
// Validate dispatcher config
|
||||
if config.Dispatcher.Type == "" {
|
||||
config.Dispatcher.Type = "simple" // Default to simple
|
||||
}
|
||||
if config.Dispatcher.URL == "" {
|
||||
return nil, fmt.Errorf("dispatcher.url is required")
|
||||
}
|
||||
|
||||
// Validate subscriptions
|
||||
for _, sub := range config.Subscriptions {
|
||||
if sub.Name == "" {
|
||||
return nil, fmt.Errorf("subscription missing name")
|
||||
}
|
||||
if sub.ContainerFilter == "" {
|
||||
return nil, fmt.Errorf("subscription %q missing container_filter", sub.Name)
|
||||
}
|
||||
if sub.LogFilter == "" {
|
||||
return nil, fmt.Errorf("subscription %q missing log_filter", sub.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Create dispatcher based on type
|
||||
var dispatcher Dispatcher
|
||||
switch config.Dispatcher.Type {
|
||||
case "simple":
|
||||
dispatcher = NewWebhookDispatcher(&config.Dispatcher)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported dispatcher type: %s", config.Dispatcher.Type)
|
||||
}
|
||||
|
||||
// Create and configure manager
|
||||
manager := NewManager(containerService, dispatcher)
|
||||
if err := manager.LoadSubscriptions(config.Subscriptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// SaveToFile saves dispatcher config and subscriptions to a YAML file
|
||||
func SaveToFile(path string, dispatcher *DispatcherConfig, subs []*Subscription) error {
|
||||
config := Config{
|
||||
Dispatcher: *dispatcher,
|
||||
Subscriptions: subs,
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(&config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal notifications config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write notifications config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
)
|
||||
|
||||
// DispatcherConfig represents global dispatcher configuration
|
||||
type DispatcherConfig struct {
|
||||
// Type determines the dispatcher type (webhook, dozzle-service, etc.)
|
||||
Type string `yaml:"type"` // "webhook" is default
|
||||
|
||||
// URL is the endpoint for the dispatcher service
|
||||
URL string `yaml:"url"`
|
||||
|
||||
// APIKey for authenticating with the dispatcher service
|
||||
APIKey string `yaml:"api_key,omitempty"`
|
||||
|
||||
// Headers to include in all requests
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// Subscription represents a notification rule
|
||||
type Subscription struct {
|
||||
// Name is a human-readable name for this subscription
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// ContainerFilter is an expr expression to match containers (REQUIRED)
|
||||
// Expression receives a Container object with: Name, Labels, Host, State
|
||||
// Example: Name == "nginx" || Labels["app"] == "web"
|
||||
ContainerFilter string `yaml:"container_filter"`
|
||||
|
||||
// LogFilter is an expr expression to match log events (REQUIRED)
|
||||
// Expression receives a Log object with: Message, Level, Timestamp, Container
|
||||
// Example: Level == "error" || Message contains "panic"
|
||||
LogFilter string `yaml:"log_filter"`
|
||||
}
|
||||
|
||||
// Container represents container information for expr evaluation (internal type)
|
||||
type Container struct {
|
||||
Name string `expr:"Name"`
|
||||
Labels map[string]string `expr:"Labels"`
|
||||
Host string `expr:"Host"`
|
||||
State string `expr:"State"`
|
||||
}
|
||||
|
||||
// Log represents log event information for expr evaluation
|
||||
type Log struct {
|
||||
Message any `expr:"Message"`
|
||||
Level string `expr:"Level"`
|
||||
Timestamp time.Time `expr:"Timestamp"`
|
||||
Container Container `expr:"Container"`
|
||||
}
|
||||
|
||||
// WebhookPayload is the structure sent to webhook endpoints
|
||||
type WebhookPayload struct {
|
||||
SubscriptionName string `json:"subscription_name"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Container Container `json:"container"`
|
||||
Log LogEvent `json:"log"`
|
||||
}
|
||||
|
||||
// LogEvent represents the log in the webhook payload
|
||||
type LogEvent struct {
|
||||
Message any `json:"message"`
|
||||
Level string `json:"level"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// NewContainer converts container.Container to notification.Container
|
||||
func NewContainer(c *container.Container) Container {
|
||||
return Container{
|
||||
Name: c.Name,
|
||||
Labels: c.Labels,
|
||||
Host: c.Host,
|
||||
State: c.State,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLog creates a Log from container.LogEvent and notification.Container
|
||||
func NewLog(logEvent *container.LogEvent, notifContainer Container) Log {
|
||||
timestamp := time.Unix(0, logEvent.Timestamp)
|
||||
|
||||
// Extract message - keep as any type for expr flexibility
|
||||
var message any = logEvent.Message
|
||||
|
||||
// For grouped logs, join fragments into single string for easier matching
|
||||
if fragments, ok := logEvent.Message.([]container.LogFragment); ok {
|
||||
parts := make([]string, len(fragments))
|
||||
for i, frag := range fragments {
|
||||
parts[i] = frag.Message
|
||||
}
|
||||
message = strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
return Log{
|
||||
Message: message,
|
||||
Level: logEvent.Level,
|
||||
Timestamp: timestamp,
|
||||
Container: notifContainer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Dispatcher is the interface for sending notifications
|
||||
type Dispatcher interface {
|
||||
Dispatch(subscription *Subscription, payload WebhookPayload)
|
||||
}
|
||||
|
||||
// WebhookDispatcher handles async webhook delivery
|
||||
type WebhookDispatcher struct {
|
||||
client *http.Client
|
||||
config *DispatcherConfig
|
||||
}
|
||||
|
||||
// NewWebhookDispatcher creates a new webhook dispatcher
|
||||
func NewWebhookDispatcher(config *DispatcherConfig) *WebhookDispatcher {
|
||||
return &WebhookDispatcher{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch sends a webhook payload asynchronously
|
||||
func (d *WebhookDispatcher) Dispatch(subscription *Subscription, payload WebhookPayload) {
|
||||
url := d.config.URL
|
||||
go func() {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("failed to marshal webhook payload")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("failed to create webhook request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Dozzle-Notifications/1.0")
|
||||
|
||||
// Add API key if configured
|
||||
if d.config.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+d.config.APIKey)
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
for key, value := range d.config.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("failed to send webhook")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
log.Warn().
|
||||
Int("status", resp.StatusCode).
|
||||
Str("url", url).
|
||||
Str("subscription", payload.SubscriptionName).
|
||||
Msg("webhook returned error status")
|
||||
} else {
|
||||
log.Debug().
|
||||
Int("status", resp.StatusCode).
|
||||
Str("url", url).
|
||||
Str("subscription", payload.SubscriptionName).
|
||||
Msg("webhook delivered successfully")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package container_support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
@@ -77,9 +78,9 @@ func (a *agentService) Attach(ctx context.Context, container container.Container
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (a *agentService) Exec(ctx context.Context, container container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||
func (a *agentService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
containerWriter, containerReader, err := a.client.ContainerExec(cancelCtx, container.ID, cmd)
|
||||
session, err := a.client.ContainerExec(cancelCtx, c.ID, cmd)
|
||||
|
||||
if err != nil {
|
||||
cancel()
|
||||
@@ -89,15 +90,35 @@ func (a *agentService) Exec(ctx context.Context, container container.Container,
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
if _, err := io.Copy(containerWriter, stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error while reading from ws using agent")
|
||||
decoder := json.NewDecoder(stdin)
|
||||
loop:
|
||||
for {
|
||||
var event container.ExecEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error().Err(err).Msg("error decoding event from ws using agent")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "userinput":
|
||||
if _, err := session.Writer.Write([]byte(event.Data)); err != nil {
|
||||
log.Error().Err(err).Msg("error writing to container using agent")
|
||||
break loop
|
||||
}
|
||||
case "resize":
|
||||
if err := session.Resize(event.Width, event.Height); err != nil {
|
||||
log.Error().Err(err).Msg("error resizing terminal using agent")
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
containerWriter.Close()
|
||||
session.Writer.Close()
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
if _, err := stdcopy.StdCopy(stdout, stdout, containerReader); err != nil {
|
||||
if _, err := stdcopy.StdCopy(stdout, stdout, session.Reader); err != nil {
|
||||
log.Error().Err(err).Msg("error while writing to ws using agent")
|
||||
}
|
||||
cancel()
|
||||
|
||||
@@ -2,6 +2,7 @@ package docker_support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -111,9 +112,9 @@ func (d *DockerClientService) SubscribeContainersStarted(ctx context.Context, co
|
||||
d.store.SubscribeNewContainers(ctx, containers)
|
||||
}
|
||||
|
||||
func (d *DockerClientService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||
func (d *DockerClientService) Attach(ctx context.Context, c container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
containerWriter, containerReader, err := d.client.ContainerAttach(cancelCtx, container.ID)
|
||||
session, err := d.client.ContainerAttach(cancelCtx, c.ID)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
@@ -122,20 +123,42 @@ func (d *DockerClientService) Attach(ctx context.Context, container container.Co
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
if _, err := io.Copy(containerWriter, stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error while reading from ws")
|
||||
decoder := json.NewDecoder(stdin)
|
||||
loop:
|
||||
for {
|
||||
var event container.ExecEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error().Err(err).Msg("error while decoding event from ws")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "userinput":
|
||||
if _, err := session.Writer.Write([]byte(event.Data)); err != nil {
|
||||
log.Error().Err(err).Msg("error while writing to container")
|
||||
break loop
|
||||
}
|
||||
case "resize":
|
||||
if err := session.Resize(event.Width, event.Height); err != nil {
|
||||
log.Error().Err(err).Msg("error while resizing terminal")
|
||||
}
|
||||
default:
|
||||
log.Warn().Str("type", event.Type).Msg("unknown event type")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
containerWriter.Close()
|
||||
session.Writer.Close()
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
if container.Tty {
|
||||
if _, err := io.Copy(stdout, containerReader); err != nil {
|
||||
if c.Tty {
|
||||
if _, err := io.Copy(stdout, session.Reader); err != nil {
|
||||
log.Error().Err(err).Msg("error while writing to ws")
|
||||
}
|
||||
} else {
|
||||
if _, err := stdcopy.StdCopy(stdout, stdout, containerReader); err != nil {
|
||||
if _, err := stdcopy.StdCopy(stdout, stdout, session.Reader); err != nil {
|
||||
log.Error().Err(err).Msg("error while writing to ws")
|
||||
}
|
||||
}
|
||||
@@ -147,25 +170,48 @@ func (d *DockerClientService) Attach(ctx context.Context, container container.Co
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DockerClientService) Exec(ctx context.Context, container container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||
func (d *DockerClientService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
containerWriter, containerReader, err := d.client.ContainerExec(cancelCtx, container.ID, cmd)
|
||||
session, err := d.client.ContainerExec(cancelCtx, c.ID, cmd)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
if _, err := io.Copy(containerWriter, stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error while reading from ws")
|
||||
decoder := json.NewDecoder(stdin)
|
||||
loop:
|
||||
for {
|
||||
var event container.ExecEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error().Err(err).Msg("error while decoding event from ws")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "userinput":
|
||||
if _, err := session.Writer.Write([]byte(event.Data)); err != nil {
|
||||
log.Error().Err(err).Msg("error while writing to container")
|
||||
break loop
|
||||
}
|
||||
case "resize":
|
||||
if err := session.Resize(event.Width, event.Height); err != nil {
|
||||
log.Error().Err(err).Msg("error while resizing terminal")
|
||||
}
|
||||
default:
|
||||
log.Warn().Str("type", event.Type).Msg("unknown event type")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
containerWriter.Close()
|
||||
session.Writer.Close()
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
if _, err := stdcopy.StdCopy(stdout, stdout, containerReader); err != nil {
|
||||
if _, err := stdcopy.StdCopy(stdout, stdout, session.Reader); err != nil {
|
||||
log.Error().Err(err).Msg("error while writing to ws")
|
||||
}
|
||||
cancel()
|
||||
|
||||
@@ -122,6 +122,12 @@ func (m *MultiHostService) SubscribeEventsAndStats(ctx context.Context, events c
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MultiHostService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) {
|
||||
for _, client := range m.manager.List() {
|
||||
client.SubscribeEvents(ctx, events)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MultiHostService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) {
|
||||
newContainers := make(chan container.Container)
|
||||
for _, client := range m.manager.List() {
|
||||
|
||||
@@ -96,6 +96,10 @@ func (m *K8sClusterService) SubscribeEventsAndStats(ctx context.Context, events
|
||||
m.client.SubscribeStats(ctx, stats)
|
||||
}
|
||||
|
||||
func (m *K8sClusterService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) {
|
||||
m.client.SubscribeEvents(ctx, events)
|
||||
}
|
||||
|
||||
func (m *K8sClusterService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) {
|
||||
newContainers := make(chan container.Container)
|
||||
m.client.SubscribeContainersStarted(ctx, newContainers)
|
||||
|
||||
@@ -2,6 +2,7 @@ package k8s_support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
@@ -92,9 +93,9 @@ func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, conta
|
||||
k.store.SubscribeNewContainers(ctx, containers)
|
||||
}
|
||||
|
||||
func (k *K8sClientService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||
func (k *K8sClientService) Attach(ctx context.Context, c container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
writer, reader, err := k.client.ContainerAttach(cancelCtx, container.ID)
|
||||
session, err := k.client.ContainerAttach(cancelCtx, c.ID)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
@@ -103,16 +104,37 @@ func (k *K8sClientService) Attach(ctx context.Context, container container.Conta
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
defer writer.Close()
|
||||
defer session.Writer.Close()
|
||||
defer cancel()
|
||||
if _, err := io.Copy(writer, stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error copying stdin")
|
||||
|
||||
decoder := json.NewDecoder(stdin)
|
||||
loop:
|
||||
for {
|
||||
var event container.ExecEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error().Err(err).Msg("error decoding event")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "userinput":
|
||||
if _, err := session.Writer.Write([]byte(event.Data)); err != nil {
|
||||
log.Error().Err(err).Msg("error writing to container")
|
||||
break loop
|
||||
}
|
||||
case "resize":
|
||||
if err := session.Resize(event.Width, event.Height); err != nil {
|
||||
log.Error().Err(err).Msg("error resizing terminal")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
if _, err := io.Copy(stdout, reader); err != nil {
|
||||
if _, err := io.Copy(stdout, session.Reader); err != nil {
|
||||
log.Error().Err(err).Msg("error copying stdout")
|
||||
}
|
||||
})
|
||||
@@ -121,9 +143,9 @@ func (k *K8sClientService) Attach(ctx context.Context, container container.Conta
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *K8sClientService) Exec(ctx context.Context, container container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||
func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
writer, reader, err := k.client.ContainerExec(cancelCtx, container.ID, cmd)
|
||||
session, err := k.client.ContainerExec(cancelCtx, c.ID, cmd)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
@@ -132,16 +154,37 @@ func (k *K8sClientService) Exec(ctx context.Context, container container.Contain
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
defer writer.Close()
|
||||
defer session.Writer.Close()
|
||||
defer cancel()
|
||||
if _, err := io.Copy(writer, stdin); err != nil {
|
||||
log.Error().Err(err).Msg("error copying stdin")
|
||||
|
||||
decoder := json.NewDecoder(stdin)
|
||||
loop:
|
||||
for {
|
||||
var event container.ExecEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error().Err(err).Msg("error decoding event")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "userinput":
|
||||
if _, err := session.Writer.Write([]byte(event.Data)); err != nil {
|
||||
log.Error().Err(err).Msg("error writing to container")
|
||||
break loop
|
||||
}
|
||||
case "resize":
|
||||
if err := session.Resize(event.Width, event.Height); err != nil {
|
||||
log.Error().Err(err).Msg("error resizing terminal")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
if _, err := io.Copy(stdout, reader); err != nil {
|
||||
if _, err := io.Copy(stdout, session.Reader); err != nil {
|
||||
log.Error().Err(err).Msg("error copying stdout")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,11 @@ func EscapeHTMLValues(logEvent *container.LogEvent) {
|
||||
case string:
|
||||
logEvent.Message = escapeAndProcessMarkers(value)
|
||||
|
||||
case []container.LogFragment:
|
||||
for i, fragment := range value {
|
||||
value[i].Message = escapeAndProcessMarkers(fragment.Message)
|
||||
}
|
||||
|
||||
case *orderedmap.OrderedMap[string, any]:
|
||||
escapeAnyMap(value)
|
||||
|
||||
|
||||
@@ -51,6 +51,16 @@ func (pm *PatternMatcher) MarkInLogEvent(logEvent *container.LogEvent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
case []container.LogFragment:
|
||||
found := false
|
||||
for i, fragment := range value {
|
||||
if pm.Regex.MatchString(fragment.Message) {
|
||||
value[i].Message = pm.Regex.ReplaceAllString(fragment.Message, pm.MarkerStart+"$0"+pm.MarkerEnd)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
|
||||
case *orderedmap.OrderedMap[string, any]:
|
||||
return pm.markMapAny(value)
|
||||
|
||||
|
||||
@@ -81,15 +81,18 @@ Content-Type: text/html
|
||||
<pre>dev</pre>
|
||||
|
||||
/* snapshot: Test_handler_between_dates */
|
||||
{"m":"INFO Testing stdout logs...","rm":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"}
|
||||
{"m":"INFO Testing stderr logs...","rm":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
|
||||
{"t":"single","m":"INFO Testing stdout logs...","rm":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"}
|
||||
{"t":"single","m":"INFO Testing stderr logs...","rm":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
|
||||
|
||||
|
||||
/* snapshot: Test_handler_between_dates_with_everything_complex */
|
||||
{"m":{"msg":"a complex log message"},"rm":"{\"msg\":\"a complex log message\"}","ts":1589396197772,"id":62280847,"l":"unknown","s":"stdout","c":"123456"}
|
||||
{"t":"complex","m":{"msg":"a complex log message"},"rm":"{\"msg\":\"a complex log message\"}","ts":1589396197772,"id":62280847,"l":"unknown","s":"stdout","c":"123456"}
|
||||
|
||||
|
||||
/* snapshot: Test_handler_between_dates_with_fill */
|
||||
{"m":"INFO Testing stdout logs...","rm":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"}
|
||||
{"m":"INFO Testing stderr logs...","rm":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
|
||||
{"t":"single","m":"INFO Testing stdout logs...","rm":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"}
|
||||
{"t":"single","m":"INFO Testing stderr logs...","rm":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
|
||||
|
||||
|
||||
/* snapshot: Test_handler_download_logs */
|
||||
INFO Testing logs...
|
||||
@@ -164,7 +167,7 @@ stdout or stderr is required
|
||||
/* snapshot: Test_handler_streamLogs_happy */
|
||||
:ping
|
||||
|
||||
data: {"m":"INFO Testing logs...\n","ts":0,"id":3835490584,"l":"info","s":"stdout","c":"123456"}
|
||||
data: {"t":"single","m":"INFO Testing logs...\n","ts":0,"id":3835490584,"l":"info","s":"stdout","c":"123456"}
|
||||
|
||||
|
||||
event: container-event
|
||||
@@ -182,7 +185,7 @@ data: {"name":"container-stopped","host":"localhost","actorId":"123456","time":"
|
||||
/* snapshot: Test_handler_streamLogs_happy_with_id */
|
||||
:ping
|
||||
|
||||
data: {"m":"INFO Testing logs...","rm":"INFO Testing logs...","ts":1589396137772,"id":2908612274,"l":"info","s":"stdout","c":"123456"}
|
||||
data: {"t":"single","m":"INFO Testing logs...","rm":"INFO Testing logs...","ts":1589396137772,"id":2908612274,"l":"info","s":"stdout","c":"123456"}
|
||||
id: 1589396137772
|
||||
|
||||
|
||||
|
||||
@@ -154,11 +154,24 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
||||
// Format timestamp in UTC
|
||||
timestamp := time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Write timestamp followed by message
|
||||
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, event.RawMessage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
|
||||
return
|
||||
// Handle grouped logs
|
||||
if event.Type == container.LogTypeGroup {
|
||||
if fragments, ok := event.Message.([]container.LogFragment); ok {
|
||||
for _, fragment := range fragments {
|
||||
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, fragment.Message)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Write timestamp followed by message for single/complex logs
|
||||
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, event.RawMessage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -62,6 +62,7 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
|
||||
config["authProvider"] = h.config.Authorization.Provider
|
||||
config["version"] = h.config.Version
|
||||
config["hostname"] = h.config.Hostname
|
||||
config["mode"] = h.config.Mode
|
||||
config["hosts"] = hosts
|
||||
config["disableAvatars"] = h.config.DisableAvatars
|
||||
config["releaseCheckMode"] = h.config.ReleaseCheckMode
|
||||
|
||||