Compare commits

...

51 Commits

Author SHA1 Message Date
Amir Raminfar f393d0d3e6 WIP: notificatons 2026-01-14 19:17:38 -08:00
Amir Raminfar 9988271490 chore: updates docs 2026-01-14 10:23:38 -08:00
Amir Raminfar 05f76c4f57 fix: fixes host limits being miscalculated (#4341) 2026-01-13 08:54:24 -08:00
Amir Raminfar 20c1adbd7e chore: fixes short formatbytes 2026-01-12 16:26:08 -08:00
Amir Raminfar d44ab349b9 feat: adds network usage for each container (#4340) 2026-01-12 14:11:19 -08:00
Amir Raminfar 21367bcc36 chore: upgrades proto (#4339) 2026-01-12 17:05:51 +00:00
renovate[bot] 6ece0df082 fix(deps): update all non-major dependencies (#4337)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 01:48:44 +00:00
Amir Raminfar b087f30fb9 chore: release v9.0.1 2026-01-07 12:05:07 -08:00
renovate[bot] 5e65e2fca5 fix(deps): update all non-major dependencies (#4329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 17:35:46 +00:00
Amir Raminfar 2303fbdfd3 fix: fixes term colors broken in latest xterm.js (#4328) 2026-01-07 09:27:19 -08:00
Amir Raminfar 078f4c4064 chore: release v9.0.0 2026-01-06 19:36:23 -08:00
Amir Raminfar 5be1a55d33 fix: fixes minor bug when there are empy lines in grouped logs 2026-01-06 19:35:33 -08:00
Amir Raminfar 09b1bae3cd chore: fixes table width for name column 2026-01-06 17:21:13 -08:00
Amir Raminfar 2a0037a667 chore: updates deps and duckdb 2026-01-06 17:15:25 -08:00
Amir Raminfar e00752b0bb feat: adds k8s menu and sets it to default in k8s mode (#4325) 2026-01-06 15:43:56 -08:00
Amir Raminfar 497e474471 perf: improves performance on the homepage (#4324) 2026-01-06 15:29:34 +00:00
Amir Raminfar 753d45d7af chore: updates claude 2026-01-05 13:23:39 -08:00
Amir Raminfar 9a2d6fc6e8 feat: support grouping of log messages when possible (#4320) 2026-01-05 21:21:12 +00:00
Amir Raminfar 4dcaa43597 fix: fixes upper case levels not working in the middle of log (#4323) 2026-01-05 12:34:36 -08:00
Amir Raminfar b51ec5bfa7 fix: fixes mobile vertical align for stats (#4322) 2026-01-05 19:48:32 +00:00
Amir Raminfar 8c8ac09521 fix: fixes container stat not working when too low (#4321) 2026-01-05 19:40:04 +00:00
Amir Raminfar 2537e16b6c chore: shows host count (#4319) 2026-01-05 17:11:05 +00:00
Amir Raminfar a8a76e29c9 feat: shows error when copy is not supported (#4318) 2026-01-04 17:57:46 -08:00
Amir Raminfar 65b2ac9181 feat: allows homepage to have collopasable sections (#4315) 2026-01-04 16:57:10 -08:00
renovate[bot] ddc4e310a8 fix(deps): update module github.com/yuin/goldmark to v1.7.14 (#4316)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 00:37:51 +00:00
Amir Raminfar 0a2afcf93c chore: adds setup script 2026-01-04 16:31:55 -08:00
Amir Raminfar 41cc1eb2ff feat: support historical stats on homepage, hosts and containers. (#4307) 2026-01-03 13:28:16 -08:00
Amir Raminfar 2a63d300a4 feat: adds group counter for containers in menu (#4313) 2026-01-03 21:20:51 +00:00
renovate[bot] fb9265204d fix(deps): update all non-major dependencies (#4314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 21:08:25 +00:00
Alec Bakholdin 41d46f95b3 docs: Traefik, Docker Swarm, Dozzle issue (#4306) 2025-12-31 10:45:35 -08:00
renovate[bot] d6ad628769 fix(deps): update dependency @xterm/xterm to v6 (#4304) 2025-12-28 19:34:58 -08:00
renovate[bot] 8c4b103468 fix(deps): update all non-major dependencies (#4303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 02:41:18 +00:00
renovate[bot] 9ee66dbe80 fix(deps): update all non-major dependencies (#4299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-22 01:39:34 +00:00
renovate[bot] 55aeb46262 fix(deps): update all non-major dependencies (#4298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 17:55:03 +00:00
renovate[bot] 1b260019a1 chore(deps): update dependency @types/node to ^25.0.3 (#4297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 19:51:13 +00:00
renovate[bot] e07d97a28a fix(deps): update all non-major dependencies (#4296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 19:00:31 +00:00
renovate[bot] 7b53cffdae fix(deps): update all non-major dependencies (#4295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 01:49:34 +00:00
Amir Raminfar d630056a61 chore: release v8.14.12 2025-12-14 08:53:05 -08:00
Amir Raminfar 1f712b56e0 fix: fixes start event to have correct time (#4294) 2025-12-14 16:39:46 +00:00
renovate[bot] c199e1fa28 chore(deps): update actions/upload-artifact action to v6 (#4292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 08:45:30 -08:00
renovate[bot] 60c8f7a255 fix(deps): update all non-major dependencies (#4291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 16:31:17 +00:00
Amir Raminfar 6bafcc6f77 chore: release v8.14.11 2025-12-12 16:15:45 -08:00
Amir Raminfar 529fb3ad6d fix: fixes terminal height on mobile (#4288) 2025-12-12 16:15:12 -08:00
Amir Raminfar 8000b6c14e feat: support shell resize (#4287) 2025-12-12 15:07:54 -08:00
Amir Raminfar b74dc9f58a chore: updates docs 2025-12-11 09:48:48 -08:00
renovate[bot] 67156b501f fix(deps): update all non-major dependencies (#4285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 17:30:40 +00:00
Amir Raminfar fd69eae2ea feat: enables action toolbar on menu (#4284) 2025-12-11 17:25:40 +00:00
renovate[bot] 9530bfeadc fix(deps): update all non-major dependencies (#4279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 03:33:55 +00:00
Amir Raminfar 1d6b4293e7 chore: updates typecheck (#4283) 2025-12-11 03:28:37 +00:00
renovate[bot] 940bcde6c6 fix(deps): update all non-major dependencies (#4276)
Deploy VitePress site to Pages / build (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 17:06:47 +00:00
renovate[bot] 836c179b4c fix(deps): update all non-major dependencies (#4271)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 06:37:25 +00:00
124 changed files with 5538 additions and 1868 deletions
+7 -7
View File
@@ -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:
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub
uses: docker/login-action@v3.6.0
with:
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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
+28
View File
@@ -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 -1
View File
@@ -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
+13
View File
@@ -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']>
+11 -3
View File
@@ -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']
+108
View File
@@ -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>
+55
View File
@@ -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>
+21 -47
View File
@@ -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]),
+154
View File
@@ -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>
+2 -111
View File
@@ -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>
+3 -3
View File
@@ -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>
+142
View File
@@ -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>
+43
View File
@@ -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>
+25 -5
View File
@@ -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() },
+1 -6
View File
@@ -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>;
+11 -12
View File
@@ -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>
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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)"
+46 -11
View File
@@ -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>
+56
View File
@@ -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>
+1 -1
View File
@@ -2,7 +2,7 @@
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
<header
v-if="$slots.header"
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
class="border-base-content/10 bg-base-200 sticky top-[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>
+23 -5
View File
@@ -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 },
+3 -3
View File
@@ -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">
+48 -10
View File
@@ -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 {
+18 -5
View File
@@ -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);
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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) => {
+4
View File
@@ -147,3 +147,7 @@ body {
[class*="shadow-"] {
@apply shadow-base-content/8;
}
.splitpanes--vertical .splitpanes__pane {
transition: none !important;
}
+5 -2
View File
@@ -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;
}
+51 -27
View File
@@ -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);
}
}
+37 -2
View File
@@ -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>
+32
View File
@@ -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>
+32
View File
@@ -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>
+11 -20
View File
@@ -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>
+1
View File
@@ -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;
+137
View File
@@ -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));
}
+26
View File
@@ -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]'
+2
View File
@@ -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 -2
View File
@@ -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> {
+4 -1
View File
@@ -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 */
+3 -1
View File
@@ -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`.
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+20
View File
@@ -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.
+3
View File
@@ -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
+1 -1
View File
@@ -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.
+2
View File
@@ -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.
+4 -1
View File
@@ -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`.
+2
View File
@@ -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?
+15 -3
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+126
View File
@@ -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"
+22 -21
View File
@@ -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
+52
View File
@@ -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=
+101 -9
View File
@@ -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 {
+324 -72
View File
@@ -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,
},
+46 -14
View File
@@ -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",
}
+181 -68
View File
@@ -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,
},
+106 -23
View File
@@ -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),
}
}
+15 -2
View File
@@ -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)
}
+123 -45
View File
@@ -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
}
}
+102
View File
@@ -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)
}
+8 -4
View File
@@ -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
}
}
}
+2
View File
@@ -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"},
+31 -12
View File
@@ -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
}
+45 -12
View File
@@ -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 {
+84 -14
View File
@@ -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
+5 -3
View File
@@ -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 {
+325
View File
@@ -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)
}
+87
View File
@@ -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
}
+104
View File
@@ -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,
}
}
+83
View File
@@ -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")
}
}()
}
+27 -6
View File
@@ -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()
+60 -14
View File
@@ -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)
+55 -12
View File
@@ -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")
}
})
+5
View File
@@ -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)
+10
View File
@@ -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)
+10 -7
View File
@@ -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
+18 -5
View File
@@ -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 {
+1
View File
@@ -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

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