feat(cloud-proto): add SearchLogs unary RPC (#4672)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-05-05 16:11:32 -07:00
committed by GitHub
parent 883f5464e3
commit 8dac197f60
57 changed files with 2048 additions and 168 deletions
+11
View File
@@ -54,6 +54,7 @@ declare global {
const drawerContext: typeof import('./composable/drawer').drawerContext const drawerContext: typeof import('./composable/drawer').drawerContext
const eagerComputed: typeof import('@vueuse/core').eagerComputed const eagerComputed: typeof import('@vueuse/core').eagerComputed
const effectScope: typeof import('vue').effectScope const effectScope: typeof import('vue').effectScope
const escapeHtml: typeof import('./utils/index').escapeHtml
const extendRef: typeof import('@vueuse/core').extendRef const extendRef: typeof import('@vueuse/core').extendRef
const flattenJSON: typeof import('./utils/index').flattenJSON const flattenJSON: typeof import('./utils/index').flattenJSON
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
@@ -68,6 +69,7 @@ declare global {
const groupContainers: typeof import('./stores/settings').groupContainers const groupContainers: typeof import('./stores/settings').groupContainers
const h: typeof import('vue').h const h: typeof import('vue').h
const hashCode: typeof import('./utils/index').hashCode const hashCode: typeof import('./utils/index').hashCode
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
const hourStyle: typeof import('./stores/settings').hourStyle const hourStyle: typeof import('./stores/settings').hourStyle
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
const inject: typeof import('vue').inject const inject: typeof import('vue').inject
@@ -158,6 +160,7 @@ declare global {
const stripVersion: typeof import('./utils/index').stripVersion const stripVersion: typeof import('./utils/index').stripVersion
const syncRef: typeof import('@vueuse/core').syncRef const syncRef: typeof import('@vueuse/core').syncRef
const syncRefs: typeof import('@vueuse/core').syncRefs const syncRefs: typeof import('@vueuse/core').syncRefs
const syntaxHighlightJson: typeof import('./utils/index').syntaxHighlightJson
const templateRef: typeof import('@vueuse/core').templateRef const templateRef: typeof import('@vueuse/core').templateRef
const throttledRef: typeof import('@vueuse/core').throttledRef const throttledRef: typeof import('@vueuse/core').throttledRef
const throttledWatch: typeof import('@vueuse/core').throttledWatch const throttledWatch: typeof import('@vueuse/core').throttledWatch
@@ -168,6 +171,7 @@ declare global {
const toRelativeTime: typeof import('./utils/index').toRelativeTime const toRelativeTime: typeof import('./utils/index').toRelativeTime
const toValue: typeof import('vue').toValue const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef const triggerRef: typeof import('vue').triggerRef
const tryFormatJson: typeof import('./utils/index').tryFormatJson
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
@@ -206,6 +210,7 @@ declare global {
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned const useCloned: typeof import('@vueuse/core').useCloned
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
const useColorMode: typeof import('@vueuse/core').useColorMode const useColorMode: typeof import('@vueuse/core').useColorMode
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useContainerActions: typeof import('./composable/containerActions').useContainerActions const useContainerActions: typeof import('./composable/containerActions').useContainerActions
@@ -254,6 +259,7 @@ declare global {
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
const useFps: typeof import('@vueuse/core').useFps const useFps: typeof import('@vueuse/core').useFps
const useFullscreen: typeof import('@vueuse/core').useFullscreen const useFullscreen: typeof import('@vueuse/core').useFullscreen
const useFuzzySearch: typeof import('./composable/fuzzySearch').useFuzzySearch
const useGamepad: typeof import('@vueuse/core').useGamepad const useGamepad: typeof import('@vueuse/core').useGamepad
const useGeolocation: typeof import('@vueuse/core').useGeolocation const useGeolocation: typeof import('@vueuse/core').useGeolocation
const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream
@@ -407,6 +413,9 @@ declare global {
export type { AlertFormOptions, ContainerResult } from './composable/alertForm' export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
import('./composable/alertForm') import('./composable/alertForm')
// @ts-ignore // @ts-ignore
export type { CloudLogHit } from './composable/cloudLogSearch'
import('./composable/cloudLogSearch')
// @ts-ignore
export type { DrawerWidth } from './composable/drawer' export type { DrawerWidth } from './composable/drawer'
import('./composable/drawer') import('./composable/drawer')
// @ts-ignore // @ts-ignore
@@ -637,6 +646,7 @@ declare module 'vue' {
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']> readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']> readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']> readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']> readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']> readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']> readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
@@ -685,6 +695,7 @@ declare module 'vue' {
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']> readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']> readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']> readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useFuzzySearch: UnwrapRef<typeof import('./composable/fuzzySearch')['useFuzzySearch']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']> readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']> readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']> readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
+9
View File
@@ -37,6 +37,7 @@ declare module 'vue' {
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default'] 'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default'] CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
CloudPopover: typeof import('./components/CloudPopover.vue')['default'] CloudPopover: typeof import('./components/CloudPopover.vue')['default']
CloudSearchInline: typeof import('./components/CloudSearchInline.vue')['default']
CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default'] CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default'] ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default'] ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
@@ -70,6 +71,9 @@ declare module 'vue' {
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default'] 'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default'] IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default'] 'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
JsonFormatted: typeof import('./components/common/JsonFormatted.vue')['default']
JsonText: typeof import('./components/common/JsonText.vue')['default']
JsonValue: typeof import('./components/common/JsonValue.vue')['default']
K8sMenu: typeof import('./components/K8sMenu.vue')['default'] K8sMenu: typeof import('./components/K8sMenu.vue')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default'] KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default'] LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
@@ -97,8 +101,10 @@ declare module 'vue' {
'Mdi:account': typeof import('~icons/mdi/account')['default'] 'Mdi:account': typeof import('~icons/mdi/account')['default']
'Mdi:alert': typeof import('~icons/mdi/alert')['default'] 'Mdi:alert': typeof import('~icons/mdi/alert')['default']
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default'] 'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default'] 'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default'] 'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:archiveOutline': typeof import('~icons/mdi/archive-outline')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default'] 'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:beer': typeof import('~icons/mdi/beer')['default'] 'Mdi:beer': typeof import('~icons/mdi/beer')['default']
'Mdi:bell': typeof import('~icons/mdi/bell')['default'] 'Mdi:bell': typeof import('~icons/mdi/bell')['default']
@@ -113,11 +119,14 @@ declare module 'vue' {
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default'] 'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default'] 'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default'] 'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
'Mdi:cloudCheckOutline': typeof import('~icons/mdi/cloud-check-outline')['default']
'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default'] 'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default']
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default'] 'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
'Mdi:cloudSearchOutline': typeof import('~icons/mdi/cloud-search-outline')['default']
'Mdi:cog': typeof import('~icons/mdi/cog')['default'] 'Mdi:cog': typeof import('~icons/mdi/cog')['default']
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default'] 'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'Mdi:docker': typeof import('~icons/mdi/docker')['default'] 'Mdi:docker': typeof import('~icons/mdi/docker')['default']
'Mdi:flash': typeof import('~icons/mdi/flash')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default'] 'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default'] 'Mdi:github': typeof import('~icons/mdi/github')['default']
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default'] 'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
+35
View File
@@ -0,0 +1,35 @@
<template>
<button
type="button"
data-testid="search"
class="bg-base-200 border-base-content/15 hover:border-primary/50 hover:bg-base-200/80 flex h-9 w-full items-center gap-2 rounded-md border px-3 text-left transition-colors"
@click="openSearch"
>
<mdi:magnify class="size-4 shrink-0" :class="cloudReady ? 'text-primary' : 'text-base-content/60'" />
<!-- Show the active query when we're on the cloud search page so the
topbar reflects what the user is looking at. -->
<span v-if="activeQuery" class="text-base-content truncate font-mono text-sm">{{ activeQuery }}</span>
<span v-else class="text-base-content/60 truncate text-sm">
<template v-if="cloudReady">{{ $t("cloud-search.hero-title-cloud") }}</template>
<template v-else>{{ $t("cloud-search.hero-title-plain") }}</template>
</span>
<span class="ml-auto flex items-center gap-1">
<kbd class="kbd kbd-xs"></kbd>
<kbd class="kbd kbd-xs">K</kbd>
</span>
</button>
</template>
<script lang="ts" setup>
import { useFuzzySearch } from "@/composable/fuzzySearch";
import { useCloudConfig } from "@/composable/cloudConfig";
const { openSearch } = useFuzzySearch();
const { cloudConfig } = useCloudConfig();
const cloudReady = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
const route = useRoute();
const activeQuery = computed(() =>
route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "",
);
</script>
+2 -2
View File
@@ -140,7 +140,7 @@ const {
cloudStatus, cloudStatus,
cloudStatusError, cloudStatusError,
isLoadingCloudStatus, isLoadingCloudStatus,
fetchCloudConfig, initialLoad,
fetchCloudStatus, fetchCloudStatus,
clearCloudState, clearCloudState,
} = useCloudConfig(); } = useCloudConfig();
@@ -199,7 +199,7 @@ async function doUnlink() {
} }
onMounted(async () => { onMounted(async () => {
await fetchCloudConfig(); await initialLoad;
if (cloudConfig.value?.linked) { if (cloudConfig.value?.linked) {
fetchCloudStatus(); fetchCloudStatus();
} }
+1 -1
View File
@@ -109,7 +109,7 @@ describe("<FuzzySearchModal />", () => {
await wrapper.find("input").setValue("foo"); await wrapper.find("input").setValue("foo");
expect(wrapper.findAll("li").length).toBe(1); expect(wrapper.findAll("li").length).toBe(1);
expect(wrapper.find("ul [data-name]").html()).toMatchInlineSnapshot( expect(wrapper.find("ul [data-name]").html()).toMatchInlineSnapshot(
`"<span data-v-dc2e8c61="" data-name=""><mark>foo</mark> bar</span>"`, `"<span data-v-dc2e8c61="" class="text-base-content" data-name=""><mark>foo</mark> bar</span>"`,
); );
}); });
+183 -52
View File
@@ -1,70 +1,159 @@
<template> <template>
<div class="dropdown dropdown-open w-full shadow-md"> <!-- Single bordered card containing the input, results, and footer in one
<div class="input input-xl input-primary flex w-full items-center"> frame to match the design mock. No daisyUI input/dropdown chrome. -->
<mdi:magnify class="flex size-8" /> <div class="bg-base-200 border-base-content/15 w-full overflow-hidden rounded-xl border shadow-2xl">
<!-- Input row -->
<div class="flex items-center gap-3 px-4 py-3.5">
<mdi:magnify class="text-base-content/60 size-5 shrink-0" />
<input <input
tabindex="0" tabindex="0"
class="input-ghost flex-1 px-1" class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
ref="input" ref="input"
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)" @keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)" @keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
@keydown.enter.exact="selected(data[selectedIndex].item)" @keydown.enter.exact="onEnter"
@keydown.shift.enter.exact.prevent="runLogSearch"
@keydown.alt.enter="addColumn(data[selectedIndex].item)" @keydown.alt.enter="addColumn(data[selectedIndex].item)"
v-model="query" v-model="query"
:placeholder="$t('placeholder.search-containers')" :placeholder="placeholderCopy"
/> />
<form method="dialog" class="flex"> <form method="dialog" class="flex">
<button v-if="isMobile"> <button v-if="isMobile" class="text-base-content/50 hover:text-base-content">
<mdi:close /> <mdi:close class="size-5" />
</button> </button>
<button v-else class="swap hover:swap-active outline-hidden"> <button v-else>
<mdi:keyboard-esc class="swap-off" /> <kbd class="kbd kbd-xs">esc</kbd>
<mdi:close class="swap-on" />
</button> </button>
</form> </form>
</div> </div>
<div
class="dropdown-content bg-base-100 relative! mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent px-2"
tabindex="0"
v-if="results.length"
>
<ul class="menu w-auto">
<li v-for="(result, index) in data" ref="listItems">
<a
class="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
@click.prevent="selected(result.item)"
:class="{ 'menu-focus': index === selectedIndex }"
>
<div :class="{ 'text-primary': result.item.state === 'running' }">
<template v-if="result.item.type === 'container'">
<octicon:container-24 />
</template>
<template v-else-if="result.item.type === 'service'">
<ph:stack-simple />
</template>
<template v-else-if="result.item.type === 'stack'">
<ph:stack />
</template>
</div>
<div class="truncate">
<template v-if="config.hosts.length > 1 && result.item.host">
<span class="font-light">{{ result.item.host }}</span> /
</template>
<span data-name v-html="matchedName(result)"></span>
</div>
<RelativeTime :date="result.item.created" class="text-xs font-light" /> <!-- Body: results + log search CTA. Only renders when there is something
<span to show keeps the empty modal compact. -->
@click.stop.prevent="addColumn(result.item)" <div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
:title="$t('tooltip.pin-column')" <!-- Containers section -->
class="hover:text-secondary" <template v-if="results.length">
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
</div>
<ul class="pb-1">
<li v-for="(result, index) in data" ref="listItems">
<a
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
:class="{ 'bg-base-content/10': index === selectedIndex }"
@click.prevent="selected(result.item)"
> >
<ic:sharp-keyboard-return v-if="index === selectedIndex" /> <div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
<cil:columns v-else-if="result.item.type === 'container'" /> <template v-if="result.item.type === 'container'">
<octicon:container-24 class="size-4" />
</template>
<template v-else-if="result.item.type === 'service'">
<ph:stack-simple class="size-4" />
</template>
<template v-else-if="result.item.type === 'stack'">
<ph:stack class="size-4" />
</template>
</div>
<div class="min-w-0 flex-1 truncate text-sm">
<template v-if="config.hosts.length > 1 && result.item.host">
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
<span class="text-base-content/30"> / </span>
</template>
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
</div>
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
<span
@click.stop.prevent="addColumn(result.item)"
:title="$t('tooltip.pin-column')"
class="text-base-content/40 hover:text-secondary"
>
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
</span>
</a>
</li>
</ul>
</template>
<!-- Log search CTA -->
<div
v-if="logSearchVisible"
class="border-base-content/10 border-t"
:class="{ 'cursor-pointer': cloudSearch.available.value, 'opacity-70': !cloudSearch.available.value }"
@click="cloudSearch.available.value && runLogSearch()"
>
<div
class="flex items-center gap-3 px-4 py-3"
:class="cloudSearch.available.value ? 'bg-primary/[0.07] hover:bg-primary/10' : ''"
>
<mdi:cloud-search-outline
class="size-5 shrink-0"
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/40'"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span
class="truncate text-sm font-semibold"
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/60'"
>
<i18n-t keypath="cloud-search.search-logs-for">
<template #query>
<span class="font-mono">{{ query }}</span>
</template>
</i18n-t>
</span> </span>
</a> <span class="text-base-content/50 mt-0.5 flex items-center gap-1 text-xs">
</li> <template v-if="cloudSearch.available.value">
</ul> <mdi:flash class="text-primary size-3" />
{{ $t("cloud-search.across-containers") }}
</template>
<template v-else-if="cloudConfig?.linked && !cloudConfig.streamLogs">
<mdi:cloud-off-outline class="size-3" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.enable-streaming-to-search") }}
</RouterLink>
</template>
<template v-else>
<mdi:cloud-off-outline class="size-3" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.connect-to-enable") }}
</RouterLink>
</template>
</span>
</div>
<kbd class="kbd kbd-xs"></kbd>
<kbd class="kbd kbd-xs"></kbd>
</div>
</div>
</div>
<!-- Footer: kbd hints + cloud status. Always present while the modal is
open so users know log search is available before they type. -->
<div
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
>
<span v-if="results.length" class="flex items-center gap-1.5">
<kbd class="kbd kbd-xs"></kbd> {{ $t("cloud-search.open-container") }}
</span>
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
<kbd class="kbd kbd-xs"></kbd><kbd class="kbd kbd-xs"></kbd>
<span class="ml-0.5">{{ $t("cloud-search.search-logs-shortcut") }}</span>
</span>
<span v-if="cloudSearch.available.value" class="ml-auto flex items-center gap-1.5">
<mdi:cloud-check-outline class="text-primary size-3.5" />
{{ $t("cloud-search.cloud-connected") }}
</span>
<span v-else-if="cloudConfig?.linked" class="ml-auto flex items-center gap-1.5">
<mdi:cloud-off-outline class="size-3.5" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.enable-streaming-to-search") }}
</RouterLink>
</span>
<span v-else class="ml-auto flex items-center gap-1.5">
<mdi:cloud-off-outline class="size-3.5" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.connect-to-enable") }}
</RouterLink>
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -73,15 +162,23 @@
import { ContainerState } from "@/types/Container"; import { ContainerState } from "@/types/Container";
import { useFuse } from "@vueuse/integrations/useFuse"; import { useFuse } from "@vueuse/integrations/useFuse";
import { type FuseResult } from "fuse.js"; import { type FuseResult } from "fuse.js";
import { useCloudConfig } from "@/composable/cloudConfig";
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
const close = defineEmit(); const close = defineEmit();
const query = ref(""); const router = useRouter();
const route = useRoute();
// Prefill with the current /cloud/search query so the user can refine
// without retyping. Empty everywhere else. Null-safe for unit tests
// that mount the component without a router context.
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
const query = ref(initialQuery);
const input = ref<HTMLInputElement>(); const input = ref<HTMLInputElement>();
const listItems = ref<HTMLInputElement[]>(); const listItems = ref<HTMLInputElement[]>();
const selectedIndex = ref(0); const selectedIndex = ref(0);
const router = useRouter();
const containerStore = useContainerStore(); const containerStore = useContainerStore();
const pinnedStore = usePinnedLogsStore(); const pinnedStore = usePinnedLogsStore();
const { visibleContainers } = storeToRefs(containerStore); const { visibleContainers } = storeToRefs(containerStore);
@@ -89,12 +186,27 @@ const { visibleContainers } = storeToRefs(containerStore);
const swarmStore = useSwarmStore(); const swarmStore = useSwarmStore();
const { stacks, services } = storeToRefs(swarmStore); const { stacks, services } = storeToRefs(swarmStore);
const { cloudConfig } = useCloudConfig();
// Mounted only so the "Search logs for X" CTA can read `available`. We
// don't render the hits inside the popup. The composable's debounced
// watch short-circuits on empty query, so opening the modal alone does
// not fire a request.
const cloudSearch = useCloudLogSearch(query);
const logSearchVisible = computed(() => query.value.trim().length > 0);
const { t } = useI18n();
const placeholderCopy = computed(() =>
cloudSearch.available.value ? t("cloud-search.modal-placeholder-cloud") : t("cloud-search.modal-placeholder-plain"),
);
onMounted(async () => { onMounted(async () => {
const dialog = input.value?.closest("dialog"); const dialog = input.value?.closest("dialog");
if (dialog) { if (dialog) {
const animations = dialog.getAnimations(); const animations = dialog.getAnimations();
await Promise.all(animations.map((animation) => animation.finished)); await Promise.all(animations.map((animation) => animation.finished));
input.value?.focus(); input.value?.focus();
if (initialQuery) input.value?.select();
} }
}); });
@@ -193,6 +305,25 @@ function selected(item: Item) {
close(); close();
} }
function onEnter() {
// Plain Enter prefers a container match if one is selected. With no
// container matches (cloud-only query like "OOM"), fall back to log search
// so the user isn't stuck on a popup that does nothing.
if (data.value.length > 0) {
selected(data.value[selectedIndex.value].item);
} else if (cloudSearch.available.value && logSearchVisible.value) {
runLogSearch();
}
}
function runLogSearch() {
if (!cloudSearch.available.value) return;
const q = query.value.trim();
if (!q) return;
router.push({ path: "/cloud/search", query: { q } });
close();
}
function addColumn(container: { id: string }) { function addColumn(container: { id: string }) {
pinnedStore.pinContainer(container); pinnedStore.pinContainer(container);
close(); close();
+3 -2
View File
@@ -41,10 +41,11 @@ defineExpose({
clear: () => (messages.value = []), clear: () => (messages.value = []),
}); });
if (historical.value && route.query.logId) { if (historical.value && typeof route.query.logId === "string") {
const targetId = route.query.logId;
watchOnce(messages, async () => { watchOnce(messages, async () => {
await nextTick(); await nextTick();
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" }); document.getElementById(targetId)?.scrollIntoView({ behavior: "instant", block: "center" });
}); });
} }
+1 -41
View File
@@ -37,7 +37,7 @@
</UseClipboard> </UseClipboard>
</div> </div>
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2"> <div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre> <JsonFormatted :value="entry.rawMessage" class="text-sm" />
</div> </div>
</section> </section>
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry"> <table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
@@ -142,28 +142,6 @@ const toggleAllFields = computed({
}, },
}); });
function syntaxHighlight(json: string) {
json = JSON.stringify(JSON.parse(json.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")), null, 2);
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
function (match: string) {
var cls = "json-number";
if (match.startsWith('"')) {
if (match.endsWith(":")) {
cls = "json-key";
} else {
cls = "json-string";
}
} else if (/true|false/.test(match)) {
cls = "json-boolean";
} else if (/null/.test(match)) {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
},
);
}
useSortable(list, fields); useSortable(list, fields);
</script> </script>
<style scoped> <style scoped>
@@ -179,22 +157,4 @@ useSortable(list, fields);
Menlo, Menlo,
monospace; monospace;
} }
pre {
& :deep(.json-key) {
@apply text-blue;
}
& :deep(.json-string) {
@apply text-green;
}
& :deep(.json-number) {
@apply text-orange;
}
& :deep(.json-boolean) {
@apply text-purple;
}
& :deep(.json-null) {
@apply text-red;
}
}
</style> </style>
+1 -8
View File
@@ -14,12 +14,7 @@
> >
{{ container.name }} {{ container.name }}
</RandomColorTag> </RandomColorTag>
<LogDate <LogDate v-if="showTimestamp" :date="logEntry.date" class="shrink-0 select-none" />
v-if="showTimestamp"
:date="logEntry.date"
class="shrink-0 select-none"
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
/>
</div> </div>
<slot /> <slot />
</div> </div>
@@ -37,6 +32,4 @@ const { hosts } = useHosts();
const container = currentContainer(toRef(() => logEntry.containerID)); const container = currentContainer(toRef(() => logEntry.containerID));
const host = computed(() => hosts.value[container.value.host]); const host = computed(() => hosts.value[container.value.host]);
const route = useRoute();
</script> </script>
+19
View File
@@ -7,6 +7,7 @@
:id="item.id.toString()" :id="item.id.toString()"
:data-time="item.date.getTime()" :data-time="item.date.getTime()"
class="group/entry" class="group/entry"
:class="{ 'log-permalink-target': permalinkLogId === item.id.toString() }"
> >
<component :is="item.getComponent()" :log-entry="item" /> <component :is="item.getComponent()" :log-entry="item" />
</li> </li>
@@ -24,6 +25,9 @@ const { messages } = defineProps<{
const { containers } = useLoggingContext(); const { containers } = useLoggingContext();
const route = useRoute();
const permalinkLogId = computed(() => (typeof route.query.logId === "string" ? route.query.logId : ""));
const list = ref<HTMLElement[]>([]); const list = ref<HTMLElement[]>([]);
let previousDate = new Date(); let previousDate = new Date();
@@ -71,6 +75,11 @@ ul {
&:last-child { &:last-child {
scroll-margin-block-end: 5rem; scroll-margin-block-end: 5rem;
} }
&.log-permalink-target {
@apply bg-secondary/15 border-secondary -ml-1 border-l-4 pl-3;
animation: log-permalink-pulse 1.4s ease-out;
}
} }
&.small { &.small {
@@ -113,4 +122,14 @@ ul {
transform: scale(1.05); transform: scale(1.05);
} }
} }
@keyframes log-permalink-pulse {
0% {
background-color: var(--color-secondary);
}
100% {
/* Settle to the resting bg-secondary/15 declared on the .li above. */
background-color: color-mix(in oklab, var(--color-secondary) 15%, transparent);
}
}
</style> </style>
+3 -2
View File
@@ -1,7 +1,8 @@
<template> <template>
<div class="flex flex-col gap-5 px-4 py-4 md:px-8"> <div class="flex flex-col gap-5 px-4 py-4 md:px-8">
<section> <section class="flex items-center gap-4">
<Links> <CloudSearchInline class="hidden max-w-sm flex-1 md:flex" />
<Links class="ml-auto">
<template #more-items> <template #more-items>
<Tag class="font-mono">{{ config.version }}</Tag> <Tag class="font-mono">{{ config.version }}</Tag>
</template> </template>
-11
View File
@@ -9,17 +9,6 @@
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small> <small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
</h1> </h1>
<button
class="input input-sm hover:border-primary mt-2 inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
@click="$emit('search')"
:title="$t('tooltip.search')"
data-testid="search"
>
<mdi:magnify />
{{ $t("placeholder.search") }}
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
</button>
<SideMenu class="flex-1" /> <SideMenu class="flex-1" />
</aside> </aside>
</template> </template>
@@ -0,0 +1,37 @@
<template>
<span class="json" :class="{ 'json-block': block }">
<JsonValue :value="parsed" :indent="block ? 0 : -1" :highlight="highlight" />
</span>
</template>
<script lang="ts" setup>
import JsonValue from "./JsonValue.vue";
const {
value,
highlight,
block = true,
} = defineProps<{
value: unknown;
highlight?: string;
block?: boolean;
}>();
const parsed = computed(() => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
});
</script>
<style scoped>
@reference "@/main.css";
.json-block {
@apply block font-mono break-all whitespace-pre-wrap;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<template v-if="!highlight">{{ text }}</template>
<template v-else>
<template v-for="(part, i) in parts" :key="i">
<mark v-if="part.match" class="bg-warning text-warning-content rounded px-0.5">{{ part.text }}</mark>
<template v-else>{{ part.text }}</template>
</template>
</template>
</template>
<script lang="ts" setup>
const { text, highlight } = defineProps<{
text: string;
highlight?: string;
}>();
const parts = computed(() => {
if (!highlight) return [{ text, match: false }];
const pattern = highlight.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
const re = new RegExp(pattern, "gi");
const result: { text: string; match: boolean }[] = [];
let last = 0;
for (const m of text.matchAll(re)) {
if (m.index! > last) result.push({ text: text.slice(last, m.index), match: false });
result.push({ text: m[0], match: true });
last = m.index! + m[0].length;
}
if (last < text.length) result.push({ text: text.slice(last), match: false });
return result;
});
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<template v-if="value === null">
<span class="json-null">null</span>
</template>
<template v-else-if="typeof value === 'boolean'">
<span class="json-boolean">{{ String(value) }}</span>
</template>
<template v-else-if="typeof value === 'number'">
<span class="json-number">{{ value }}</span>
</template>
<template v-else-if="typeof value === 'string'">
<span class="json-string">"<JsonText :text="value" :highlight="highlight" />"</span>
</template>
<template v-else-if="Array.isArray(value)">
<template v-if="value.length === 0">
<span>[]</span>
</template>
<template v-else-if="indent < 0">
<span>[</span>
<template v-for="(item, i) in value" :key="i">
<JsonValue :value="item" :indent="indent" :highlight="highlight" />
<span v-if="i < value.length - 1">, </span>
</template>
<span>]</span>
</template>
<template v-else>
<span>[</span>
<template v-for="(item, i) in value" :key="i">
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
<JsonValue :value="item" :indent="indent + 1" :highlight="highlight" />
<span v-if="i < value.length - 1">,</span>
</template>
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
<span>]</span>
</template>
</template>
<template v-else-if="typeof value === 'object'">
<template v-if="entries.length === 0">
<span>{}</span>
</template>
<template v-else-if="indent < 0">
<span>{</span>
<template v-for="([k, v], i) in entries" :key="k">
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
<JsonValue :value="v" :indent="indent" :highlight="highlight" />
<span v-if="i < entries.length - 1">, </span>
</template>
<span>}</span>
</template>
<template v-else>
<span>{</span>
<template v-for="([k, v], i) in entries" :key="k">
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
<JsonValue :value="v" :indent="indent + 1" :highlight="highlight" />
<span v-if="i < entries.length - 1">,</span>
</template>
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
<span>}</span>
</template>
</template>
<template v-else>
<span>{{ String(value) }}</span>
</template>
</template>
<script lang="ts" setup>
import JsonText from "./JsonText.vue";
const {
value,
indent = 0,
highlight,
} = defineProps<{
value: unknown;
indent?: number;
highlight?: string;
}>();
const entries = computed(() =>
value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value as Record<string, unknown>) : [],
);
function pad(level: number): string {
return " ".repeat(Math.max(level, 0));
}
</script>
<style scoped>
@reference "@/main.css";
.json-key {
@apply text-blue;
}
.json-string {
@apply text-green;
}
.json-number {
@apply text-orange;
}
.json-boolean {
@apply text-purple;
}
.json-null {
@apply text-red;
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@
<ph:command v-if="isMac" class="size-4" /> <ph:command v-if="isMac" class="size-4" />
<ph:control-bold v-else class="size-4" /> <ph:control-bold v-else class="size-4" />
</template> </template>
<kbd class="uppercase">{{ char }}</kbd> <kbd class="kbd kbd-xs ml-0.5 uppercase">{{ char }}</kbd>
</div> </div>
</template> </template>
+5
View File
@@ -19,6 +19,10 @@ async function fetchCloudConfig() {
} }
} }
// Loaded once at module import (i.e. app boot). Every consumer reads the
// shared `cloudConfig` ref — no per-component fetch.
const initialLoad = fetchCloudConfig();
async function fetchCloudStatus() { async function fetchCloudStatus() {
if (!cloudConfig.value?.linked) return; if (!cloudConfig.value?.linked) return;
isLoadingCloudStatus.value = true; isLoadingCloudStatus.value = true;
@@ -49,6 +53,7 @@ export function useCloudConfig() {
cloudStatus, cloudStatus,
cloudStatusError, cloudStatusError,
isLoadingCloudStatus, isLoadingCloudStatus,
initialLoad,
fetchCloudConfig, fetchCloudConfig,
fetchCloudStatus, fetchCloudStatus,
clearCloudState, clearCloudState,
+155
View File
@@ -0,0 +1,155 @@
import { useCloudConfig } from "@/composable/cloudConfig";
export interface CloudLogHit {
ts: number;
hostId: string;
containerId: string;
containerName: string;
message: string;
stream: string;
level: string;
// Dozzle's deterministic FNV-32a id for the raw log line — used to deep-link
// to the exact line in the local log viewer. Optional: pre-indexing logs
// (or older Dozzle clients) won't have it.
logId?: number;
}
interface CloudLogSearchResponse {
hits: CloudLogHit[];
hasMore: boolean;
// Cursor for the next older page. Pass back as `before=` in the URL.
// Omitted when there's nothing more to load.
nextBefore?: number;
}
const debounceMs = 250;
/**
* useCloudLogSearch performs Cloud-side log search via the Dozzle backend's
* /api/cloud/search/logs endpoint. Identity is derived server-side from the
* authenticated gRPC connection; this composable passes only the query.
*
* Behavior:
* - debounced 250ms; whitespace-only short-circuits to []
* - aborts any in-flight request on each new keystroke (AbortController)
* - `available` is computed: cloud linked AND streamLogs enabled
* - when `available` is false, results stay [] regardless of query
*
* Status mapping:
* 200 -> hits populated (may be empty)
* 204 -> streaming disabled server-side (defense-in-depth)
* 503 -> cloud not configured
* 504 -> timeout (500ms upstream)
* any other 4xx/5xx -> error set, results cleared
*/
export function useCloudLogSearch(query: Ref<string>) {
const { cloudConfig } = useCloudConfig();
const results = ref<CloudLogHit[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref<Error | null>(null);
const hasMore = ref(false);
// Cursor (timestamp_ns) of the last hit on the current page; 0 = at the
// newest page. Cleared on every new query.
const nextBefore = ref<number>(0);
const available = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
// Two parallel fetch lifecycles — keystroke search (cancels on next
// keystroke / unmount) and pagination loadMore (cancels on unmount or
// when a new query lands and supersedes pagination state). Tracking
// them separately avoids the keystroke aborter cancelling an in-flight
// pagination request and vice versa.
let abortController: AbortController | null = null;
let loadMoreAborter: AbortController | null = null;
function clearResults() {
results.value = [];
error.value = null;
loading.value = false;
loadingMore.value = false;
hasMore.value = false;
nextBefore.value = 0;
}
async function fetchPage(q: string, before: number, signal: AbortSignal): Promise<CloudLogSearchResponse | null> {
let url = withBase(`/api/cloud/search/logs?q=${encodeURIComponent(q)}&limit=20`);
if (before > 0) url += `&before=${before}`;
const res = await fetch(url, { signal });
if (res.status === 204) return { hits: [], hasMore: false };
if (!res.ok) throw new Error(`cloud search failed: ${res.status}`);
return (await res.json()) as CloudLogSearchResponse;
}
async function runSearch(q: string) {
if (abortController) abortController.abort();
// A fresh query supersedes any in-flight pagination — that page is
// for the previous query and would be appended onto the wrong result
// set if it landed late.
loadMoreAborter?.abort();
abortController = new AbortController();
loading.value = true;
error.value = null;
nextBefore.value = 0;
try {
const body = await fetchPage(q, 0, abortController.signal);
if (!body) return;
results.value = body.hits ?? [];
hasMore.value = !!body.hasMore;
nextBefore.value = body.nextBefore ?? 0;
} catch (e) {
if ((e as DOMException)?.name !== "AbortError") {
error.value = e as Error;
results.value = [];
hasMore.value = false;
}
} finally {
loading.value = false;
}
}
// loadMore appends the next older page. Safe to call repeatedly — guarded
// by hasMore + a separate loading flag so the input-debounced search and
// the user-triggered pagination don't trip each other.
async function loadMore() {
if (loadingMore.value || !hasMore.value || nextBefore.value <= 0) return;
const q = query.value.trim();
if (!q) return;
loadingMore.value = true;
loadMoreAborter?.abort();
loadMoreAborter = new AbortController();
try {
const body = await fetchPage(q, nextBefore.value, loadMoreAborter.signal);
if (!body) return;
results.value = [...results.value, ...(body.hits ?? [])];
hasMore.value = !!body.hasMore;
nextBefore.value = body.nextBefore ?? 0;
} catch (e) {
if ((e as DOMException)?.name !== "AbortError") error.value = e as Error;
} finally {
loadingMore.value = false;
}
}
watchDebounced(
[query, available],
([q, isAvailable]) => {
const trimmed = q.trim();
if (!isAvailable || trimmed === "") {
clearResults();
return;
}
runSearch(trimmed);
},
{ debounce: debounceMs, immediate: true },
);
onScopeDispose(() => {
abortController?.abort();
loadMoreAborter?.abort();
});
return { results, loading, loadingMore, error, available, hasMore, loadMore };
}
+16
View File
@@ -0,0 +1,16 @@
// Shared open state for the global Cmd+K fuzzy-search modal.
// Lives outside the layout so any surface (home page hero, mobile menu,
// sidebar trigger) can open the same modal without prop-drilling.
const open = ref(false);
export function useFuzzySearch() {
return {
open,
openSearch: () => {
open.value = true;
},
closeSearch: () => {
open.value = false;
},
};
}
+6 -8
View File
@@ -3,7 +3,7 @@
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu> <MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
<Splitpanes @resized="onResized($event)"> <Splitpanes @resized="onResized($event)">
<Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden"> <Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
<SidePanel @search="showFuzzySearch" /> <SidePanel />
</Pane> </Pane>
<Pane min-size="10" :size="100 - menuWidth"> <Pane min-size="10" :size="100 - menuWidth">
<Splitpanes> <Splitpanes>
@@ -34,9 +34,9 @@
<mdi:chevron-left class="swap-off" /> <mdi:chevron-left class="swap-off" />
</label> </label>
</div> </div>
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="open = false"> <dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none"> <div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
<FuzzySearchModal @close="open = false" v-if="open" /> <FuzzySearchModal @close="closeSearch" v-if="open" />
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button>close</button>
@@ -63,8 +63,10 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const drawer = useTemplateRef<InstanceType<typeof SideDrawer>>("drawer") as Ref<InstanceType<typeof SideDrawer>>; const drawer = useTemplateRef<InstanceType<typeof SideDrawer>>("drawer") as Ref<InstanceType<typeof SideDrawer>>;
const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer); const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer);
import { useFuzzySearch } from "@/composable/fuzzySearch";
const modal = ref<HTMLDialogElement>(); const modal = ref<HTMLDialogElement>();
const open = ref(false); const { open, openSearch: showFuzzySearch, closeSearch } = useFuzzySearch();
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const forceMenuHidden = ref(searchParams.has("hideMenu")); const forceMenuHidden = ref(searchParams.has("hideMenu"));
@@ -83,10 +85,6 @@ onKeyStroke("k", (e) => {
} }
}); });
function showFuzzySearch() {
open.value = true;
}
function onResized({ panes }: { panes: { size: number }[] }) { function onResized({ panes }: { panes: { size: number }[] }) {
if (panes.length == 2) { if (panes.length == 2) {
menuWidth.value = Math.min(panes[0].size, 50); menuWidth.value = Math.min(panes[0].size, 50);
+3
View File
@@ -214,3 +214,6 @@ body {
.status-pill-warning { .status-pill-warning {
@apply text-warning border-warning/30 bg-warning/10; @apply text-warning border-warning/30 bg-warning/10;
} }
.status-pill-error {
@apply text-error border-error/30 bg-error/10;
}
+232
View File
@@ -0,0 +1,232 @@
<template>
<PageWithLinks>
<section>
<!-- Header -->
<div class="mb-5 flex items-center gap-3">
<h2 class="text-lg font-semibold">{{ $t("cloud-search.results-page-title") }}</h2>
<span v-if="committedQuery" class="text-base-content/70 font-mono text-sm">"{{ committedQuery }}"</span>
<span v-if="cloudSearch.available.value" class="status-pill status-pill-primary ml-auto">
<mdi:flash class="size-3" /> {{ $t("cloud-search.hero-pill-indexed") }}
</span>
</div>
<!-- Status line -->
<div class="text-base-content/70 mb-3 flex h-5 items-center gap-2 text-xs">
<template v-if="cloudSearch.loading.value">
<span class="loading loading-spinner loading-xs"></span>
<span>{{ $t("cloud-search.searching") }}</span>
</template>
<template v-else-if="cloudSearch.error.value">
<mdi:alert-circle-outline class="text-error size-3.5" />
<span>{{ $t("cloud-search.search-failed") }}</span>
</template>
<template v-else-if="committedQuery && hits.length === 0">
<span>{{ $t("cloud-search.no-results") }}</span>
</template>
<template v-else-if="!committedQuery">
<span>{{ $t("cloud-search.search-empty-prompt") }}</span>
</template>
<template v-else>
<span class="font-mono">{{ $t("cloud-search.hits-count", { n: hits.length }) }}</span>
<span class="text-base-content/50">{{ $t("cloud-search.window-suffix") }}</span>
</template>
</div>
<!-- Results table matches the visual style of ContainerTable -->
<div v-if="hits.length" class="rounded-box border-base-content/10 overflow-x-auto border">
<table class="table-md md:table-lg table-zebra table">
<thead>
<tr>
<th class="text-base-content/60 w-44 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-time") }}
</th>
<th class="text-base-content/60 w-20 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-level") }}
</th>
<th class="text-base-content/60 w-1 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-container") }}
</th>
<th class="text-base-content/60 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-message") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(hit, i) in hits"
:key="`${hit.containerId}-${hit.ts}-${hit.logId ?? 0}-${i}`"
class="hover:bg-primary/5 transition-colors"
:class="{ 'cursor-pointer': isLive(hit) }"
@click="isLive(hit) && openContainer(hit)"
>
<td class="text-base-content/70 font-mono text-xs whitespace-nowrap tabular-nums">
{{ formatTs(hit.ts) }}
</td>
<td>
<span class="status-pill" :class="levelPillClass(hit.level)">{{ hit.level || "info" }}</span>
</td>
<td class="whitespace-nowrap">
<span class="inline-flex items-center gap-2">
<span :class="isLive(hit) ? 'text-base-content' : 'text-base-content/60'">
{{ hit.containerName }}
</span>
<span
v-if="!isLive(hit)"
:title="$t('cloud-search.container-removed')"
class="status-pill status-pill-neutral"
>
{{ $t("cloud-search.container-removed-pill") }}
</span>
</span>
</td>
<td>
<JsonFormatted
v-if="isJson(hit.message)"
:value="hit.message"
:highlight="committedQuery"
class="text-xs"
/>
<span v-else class="font-mono text-xs" v-html="highlight(hit.message, committedQuery)"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="hits.length && (cloudSearch.hasMore.value || cloudSearch.loadingMore.value)"
class="text-base-content/60 mt-4 flex h-10 items-center justify-center text-xs"
>
<span v-if="cloudSearch.loadingMore.value" class="loading loading-spinner loading-xs"></span>
</div>
<!-- Cloud-not-available state -->
<div
v-if="!cloudSearch.available.value && committedQuery"
class="bg-base-200 border-base-content/10 rounded-box border p-8 text-center"
>
<mdi:cloud-off-outline class="text-base-content/40 mx-auto mb-3 size-10" />
<p class="text-base-content/80 text-sm">
{{
cloudConfig?.linked ? $t("cloud-search.enable-streaming-to-search") : $t("cloud-search.connect-to-enable")
}}
</p>
<RouterLink to="/settings/cloud" class="btn btn-primary btn-sm mt-4">
{{ $t("cloud-search.cta-settings") }}
</RouterLink>
</div>
</section>
</PageWithLinks>
</template>
<script lang="ts" setup>
import { useCloudConfig } from "@/composable/cloudConfig";
import { useCloudLogSearch, type CloudLogHit } from "@/composable/cloudLogSearch";
const route = useRoute();
const router = useRouter();
function readQ(q: unknown): string {
return typeof q === "string" ? q : "";
}
const committedQuery = ref(readQ(route.query.q));
const { cloudConfig } = useCloudConfig();
const cloudSearch = useCloudLogSearch(committedQuery);
const hits = computed<CloudLogHit[]>(() => cloudSearch.results.value);
// Look up containers in the live store so we can mark hits whose containers
// have been removed (or never existed for this Dozzle instance) as
// non-clickable. Reactive — if a container is removed mid-session, the
// corresponding row updates instantly.
const containerStore = useContainerStore();
const liveIds = computed(() => new Set(Object.keys(containerStore.allContainersById)));
function isLive(hit: CloudLogHit): boolean {
return liveIds.value.has(hit.containerId);
}
// Infinite scroll: VueUse fires loadMore when the page is scrolled within
// 200px of the bottom. canLoadMore short-circuits both during a fetch and
// when the server reports no more pages, so we don't double-fire.
useInfiniteScroll(document, () => cloudSearch.loadMore(), {
distance: 200,
canLoadMore: () => cloudSearch.hasMore.value && !cloudSearch.loadingMore.value,
});
watch(
() => route.query.q,
(q) => {
committedQuery.value = readQ(q);
},
);
function formatTs(ns: number): string {
const d = new Date(ns / 1e6);
const date = d.toLocaleDateString([], { month: "short", day: "numeric" });
const time = d.toLocaleTimeString([], { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
return `${date} ${time}`;
}
function levelPillClass(level: string): string {
switch ((level || "").toLowerCase()) {
case "error":
case "fatal":
return "status-pill-error";
case "warn":
case "warning":
return "status-pill-warning";
case "info":
return "status-pill-primary";
default:
return "status-pill-neutral";
}
}
// Safe with v-html: escapeHtml runs first, then <mark> tags are added against
// a regex anchored on the (already-escaped) needle. Don't drop the escape
// thinking it's redundant — the message comes from indexed log content.
function highlight(message: string, q: string): string {
if (!q) return escapeHtml(message);
const pattern = q.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
const re = new RegExp(`(${pattern})`, "gi");
return escapeHtml(message).replace(re, '<mark class="bg-warning text-warning-content rounded px-0.5">$1</mark>');
}
function escapeHtml(s: string): string {
return s.replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
}
function isJson(message: string): boolean {
const trimmed = message.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === "object";
} catch {
return false;
}
}
function openContainer(hit: CloudLogHit) {
// Match Dozzle's permanent-link route: /container/:id/time/:datetime?logId=...
// hit.ts is unix nanoseconds; convert to ms then ISO 8601 with millis.
const datetime = new Date(hit.ts / 1e6).toISOString();
const query: Record<string, string> = {};
if (hit.logId !== undefined && hit.logId !== 0) {
// logId pinpoints the exact line; the historical-logs view scrolls to it.
query.logId = String(hit.logId);
}
if (committedQuery.value) {
query.q = committedQuery.value;
}
router.push({
name: "/container/[id].time.[datetime]",
params: { id: hit.containerId, datetime },
query,
});
}
</script>
+13
View File
@@ -44,6 +44,13 @@ declare module 'vue-router/auto-routes' {
{ all: ParamValue<false> }, { all: ParamValue<false> },
| never | never
>, >,
'/cloud/search': RouteRecordInfo<
'/cloud/search',
'/cloud/search',
Record<never, never>,
Record<never, never>,
| never
>,
'/container/[id]': RouteRecordInfo< '/container/[id]': RouteRecordInfo<
'/container/[id]', '/container/[id]',
'/container/:id', '/container/:id',
@@ -167,6 +174,12 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'assets/pages/cloud/search.vue': {
routes:
| '/cloud/search'
views:
| never
}
'assets/pages/container/[id].vue': { 'assets/pages/container/[id].vue': {
routes: routes:
| '/container/[id]' | '/container/[id]'
+1 -1
View File
@@ -19,7 +19,7 @@ test("click on settings button", async ({ page }) => {
test("shortcut for fuzzy search", async ({ page }) => { test("shortcut for fuzzy search", async ({ page }) => {
await page.locator("body").press("Control+k"); await page.locator("body").press("Control+k");
await expect(page.locator(".modal").getByPlaceholder("Search containers (⌘ + k, ⌃k)")).toBeVisible(); await expect(page.locator(".modal").getByPlaceholder("Search containers")).toBeVisible();
}); });
test("route by name", async ({ page }) => { test("route by name", async ({ page }) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+8
View File
@@ -51,6 +51,14 @@ type Client struct {
connMu sync.Mutex connMu sync.Mutex
cancelCurrent context.CancelFunc cancelCurrent context.CancelFunc
// searchConn / searchClient are lazily initialized and shared across
// SearchLogs calls so we don't pay the TLS handshake on every keystroke.
// Same target / TLS as the main ToolStream conn; per-call identity is
// supplied via metadata (x-api-key, x-instance-id), so one conn is fine.
searchConnMu sync.Mutex
searchConn *grpc.ClientConn
searchClient pb.CloudToolServiceClient
} }
// NewClient creates a new cloud gRPC client. // NewClient creates a new cloud gRPC client.
+1
View File
@@ -207,6 +207,7 @@ func (ls *logStreamer) runReader(ctx context.Context, cs *container_support.Cont
Message: msg, Message: msg,
Stream: ev.Stream, Stream: ev.Stream,
Level: level, Level: level,
LogId: ev.Id,
}) })
batchBytes += len(msg) batchBytes += len(msg)
+123
View File
@@ -0,0 +1,123 @@
package cloud
import (
"context"
"errors"
"fmt"
pb "github.com/amir20/dozzle/proto/cloud"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// SearchLogResult is the JSON-friendly response shape returned to the Dozzle
// web layer. Mirrors the proto SearchLogsResponse but lives in this package
// so callers don't have to import the proto package directly.
type SearchLogResult struct {
Hits []SearchLogHit `json:"hits"`
HasMore bool `json:"hasMore"`
// NextBefore is the cursor to pass back as `before` (HTTP) /
// before_ts_ns (gRPC) to fetch the next older page. 0 when HasMore
// is false.
NextBefore int64 `json:"nextBefore,omitempty"`
}
// SearchLogHit is one matched log line, scoped server-side to the connecting
// instance's (user_id, api_key_id) — Cloud derives those from the auth
// metadata, never the request body.
type SearchLogHit struct {
TimestampNs int64 `json:"ts"`
HostID string `json:"hostId"`
ContainerID string `json:"containerId"`
ContainerName string `json:"containerName"`
Message string `json:"message"`
Stream string `json:"stream"`
Level string `json:"level"`
// LogID is Dozzle's FNV-32a hash of the original line. Lets the UI
// build deep-links matching "Copy permalink" output. Omitted when the
// row predates indexing (older Dozzle clients sent 0).
LogID uint32 `json:"logId,omitempty"`
}
// ErrNotConfigured is returned when SearchLogs is called but no Cloud API key
// is available (the user hasn't linked Cloud yet). Callers map this to a 503.
var ErrNotConfigured = errors.New("cloud: no API key configured")
// searchServiceClient returns a (lazily dialed) reusable gRPC client. The
// underlying conn is shared across all SearchLogs calls so we pay the TLS
// handshake once per process — not once per keystroke.
func (c *Client) searchServiceClient() (pb.CloudToolServiceClient, error) {
c.searchConnMu.Lock()
defer c.searchConnMu.Unlock()
if c.searchClient != nil {
return c.searchClient, nil
}
var creds grpc.DialOption
if c.plaintext {
creds = grpc.WithTransportCredentials(insecure.NewCredentials())
} else {
creds = grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))
}
conn, err := grpc.NewClient(c.target, creds)
if err != nil {
return nil, fmt.Errorf("cloud: dial: %w", err)
}
c.searchConn = conn
c.searchClient = pb.NewCloudToolServiceClient(conn)
return c.searchClient, nil
}
// SearchLogs runs a Cloud-side log search against the existing gRPC service.
// Reuses a long-lived gRPC conn (lazily dialed on first call) so the
// 500ms search timeout isn't burned on a TLS handshake per keystroke.
// Identity (user, instance) is enforced server-side from the authenticated
// metadata; this client passes only the per-request fields below.
func (c *Client) SearchLogs(ctx context.Context, query string, limit int32, hostID, containerID string, before int64) (*SearchLogResult, error) {
apiKey := c.apiKeyFunc()
if apiKey == "" {
return nil, ErrNotConfigured
}
client, err := c.searchServiceClient()
if err != nil {
return nil, err
}
mdPairs := []string{"x-api-key", apiKey}
if c.instanceID != "" {
mdPairs = append(mdPairs, "x-instance-id", c.instanceID)
}
callCtx := metadata.NewOutgoingContext(ctx, metadata.Pairs(mdPairs...))
resp, err := client.SearchLogs(callCtx, &pb.SearchLogsRequest{
Query: query,
Limit: limit,
HostId: hostID,
ContainerId: containerID,
BeforeTsNs: before,
})
if err != nil {
return nil, fmt.Errorf("cloud: search: %w", err)
}
hits := make([]SearchLogHit, 0, len(resp.GetHits()))
for _, h := range resp.GetHits() {
hits = append(hits, SearchLogHit{
TimestampNs: h.GetTimestampNs(),
HostID: h.GetHostId(),
ContainerID: h.GetContainerId(),
ContainerName: h.GetContainerName(),
Message: h.GetMessage(),
Stream: h.GetStream(),
Level: h.GetLevel(),
LogID: h.GetLogId(),
})
}
return &SearchLogResult{
Hits: hits,
HasMore: resp.GetHasMore(),
NextBefore: resp.GetNextBeforeTsNs(),
}, nil
}
+13 -1
View File
@@ -128,7 +128,19 @@ func (g *EventGenerator) skipOrphanedLines() *LogEvent {
if !isOrphan { if !isOrphan {
if len(orphanBuffer) > 0 { if len(orphanBuffer) > 0 {
log.Debug().Int("count", len(orphanBuffer)).Str("container", g.containerID).Msg("skipped orphaned continuation lines") // If the chain broke because `current` is far in time from the
// last buffered line, the buffered lines weren't continuations
// of anything — they're real isolated entries. Emit them as
// singles so first-of-window lines aren't silently dropped
// (e.g. postgres "checkpoint starting: time" — only entry in
// a 5-min window followed by a 0.4s-later "complete" line).
timeGap := lastTimestamp != 0 && current.Timestamp > 0 &&
math.Abs(float64(lastTimestamp-current.Timestamp)) >= maxGroupTimeDelta
if timeGap {
g.emitAsSingles(orphanBuffer)
} else {
log.Debug().Int("count", len(orphanBuffer)).Str("container", g.containerID).Msg("skipped orphaned continuation lines")
}
} }
return current return current
} }
+11 -5
View File
@@ -360,8 +360,11 @@ func TestEventGenerator_OrphanNotSkipped_AllLevellessLines(t *testing.T) {
} }
func TestEventGenerator_OrphanNotSkipped_TimestampGapBreaksOrphanDetection(t *testing.T) { func TestEventGenerator_OrphanNotSkipped_TimestampGapBreaksOrphanDetection(t *testing.T) {
// Lines far apart in time — first is buffered as orphan candidate, but the // Lines far apart in time — first is buffered as orphan candidate but the
// gap breaks the chain so it's treated as non-orphan. // gap breaks the chain. Both must be emitted: a single isolated levelless
// line is not an orphan continuation, and dropping it loses real user
// content (e.g. postgres "checkpoint starting: time" is the first event
// of every 5-min historical window).
containerStart := time.Date(2020, 5, 13, 10, 0, 0, 0, time.UTC) containerStart := time.Date(2020, 5, 13, 10, 0, 0, 0, time.UTC)
messages := []string{ messages := []string{
"2020-05-13T18:55:37.000Z some log without level", "2020-05-13T18:55:37.000Z some log without level",
@@ -375,12 +378,15 @@ func TestEventGenerator_OrphanNotSkipped_TimestampGapBreaksOrphanDetection(t *te
g := NewEventGenerator(context.Background(), reader, Container{Tty: false, StartedAt: containerStart}) g := NewEventGenerator(context.Background(), reader, Container{Tty: false, StartedAt: containerStart})
// First line is buffered as orphan candidate, but the second has a timestamp
// gap so it's not an orphan — first is skipped, second is emitted.
event1 := <-g.Events event1 := <-g.Events
require.NotNil(t, event1) require.NotNil(t, event1)
assert.Equal(t, LogTypeSingle, event1.Type) assert.Equal(t, LogTypeSingle, event1.Type)
assert.Equal(t, "another log without level", event1.Message) assert.Equal(t, "some log without level", event1.Message)
event2 := <-g.Events
require.NotNil(t, event2)
assert.Equal(t, LogTypeSingle, event2.Type)
assert.Equal(t, "another log without level", event2.Message)
} }
func TestEventGenerator_OrphanNotSkipped_NoTimestamp(t *testing.T) { func TestEventGenerator_OrphanNotSkipped_NoTimestamp(t *testing.T) {
+10 -10
View File
@@ -96,11 +96,11 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
// Drop any existing connection so a relink with a new key takes effect // Drop any existing connection so a relink with a new key takes effect
// immediately instead of riding out the old stream. // immediately instead of riding out the old stream.
if h.config.OnCloudUpdate != nil { if h.config.Cloud.OnUpdate != nil {
h.config.OnCloudUpdate() h.config.Cloud.OnUpdate()
} }
if h.config.OnCloudSetup != nil { if h.config.Cloud.OnSetup != nil {
h.config.OnCloudSetup() h.config.Cloud.OnSetup()
} }
base := h.config.Base base := h.config.Base
@@ -174,8 +174,8 @@ func (h *handler) cloudStatus(w http.ResponseWriter, r *http.Request) {
} `json:"plan"` } `json:"plan"`
} }
if json.Unmarshal(body, &statusResp) == nil && statusResp.Plan.Name == "pro" { if json.Unmarshal(body, &statusResp) == nil && statusResp.Plan.Name == "pro" {
if h.config.OnCloudSetup != nil { if h.config.Cloud.OnSetup != nil {
h.config.OnCloudSetup() h.config.Cloud.OnSetup()
} }
} }
@@ -234,8 +234,8 @@ func (h *handler) updateCloudConfig(w http.ResponseWriter, r *http.Request) {
h.hostService.SetCloudStreamLogs(*req.StreamLogs) h.hostService.SetCloudStreamLogs(*req.StreamLogs)
// Drop the active cloud connection so the new flag is picked up on // Drop the active cloud connection so the new flag is picked up on
// the next dial — a streamer may need to start or stop. // the next dial — a streamer may need to start or stop.
if h.config.OnCloudUpdate != nil { if h.config.Cloud.OnUpdate != nil {
h.config.OnCloudUpdate() h.config.Cloud.OnUpdate()
} }
} }
@@ -247,8 +247,8 @@ func (h *handler) deleteCloudConfig(w http.ResponseWriter, r *http.Request) {
// Drop the active cloud connection so the server stops seeing this // Drop the active cloud connection so the server stops seeing this
// instance immediately, instead of riding out the existing stream with // instance immediately, instead of riding out the existing stream with
// a now-deleted key. // a now-deleted key.
if h.config.OnCloudUpdate != nil { if h.config.Cloud.OnUpdate != nil {
h.config.OnCloudUpdate() h.config.Cloud.OnUpdate()
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/amir20/dozzle/internal/cloud"
"github.com/rs/zerolog/log"
)
// cloudSearchTimeout caps the round-trip to Doligence Cloud. Search is on
// the keystroke path; we'd rather show "no results" than block typing.
const cloudSearchTimeout = 500 * time.Millisecond
// cloudSearchLogs proxies a search query to Doligence Cloud over the existing
// authenticated gRPC connection. Identity is derived server-side from the
// API key — this handler passes neither user nor instance ids.
//
// Status mapping:
// 200 — hits returned (may be empty)
// 204 — streamLogs is disabled; nothing to search
// 503 — cloud not configured (no API key) or no SearchLogs func wired
// 504 — cloud round-trip exceeded the search timeout
// 502 — any other cloud-side error
func (h *handler) cloudSearchLogs(w http.ResponseWriter, r *http.Request) {
if h.config.Cloud.SearchLogs == nil {
writeError(w, http.StatusServiceUnavailable, "cloud not configured")
return
}
cc := h.hostService.CloudConfig()
if cc == nil || !cc.StreamLogsEnabled() {
// Defense in depth — the UI already gates on streamLogs, but a stale
// flag client-side mustn't trigger spurious cloud queries.
w.WriteHeader(http.StatusNoContent)
return
}
q := r.URL.Query().Get("q")
if q == "" {
writeError(w, http.StatusBadRequest, "missing q")
return
}
// Defense in depth: the UI input is short (debounced typing) but a
// malicious client could POST any size. Reject anything past 512
// chars rather than fan it out to Cloud's gRPC backend.
if len(q) > 512 {
writeError(w, http.StatusBadRequest, "q too long")
return
}
// Cloud caps server-side at 50; mirror it here so a misbehaving client
// can't tie up the keystroke path with an oversized request. ParseInt
// with bitSize=32 guarantees the value fits in int32, so the cast is
// provably safe (out-of-range parses return an error and fall through).
limit := int32(20)
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.ParseInt(v, 10, 32); err == nil && n > 0 {
if n > 50 {
n = 50
}
limit = int32(n)
}
}
hostID := r.URL.Query().Get("hostId")
containerID := r.URL.Query().Get("containerId")
// Pagination cursor — pass-through to Cloud. 0 (the default) means
// "newest"; subsequent pages send back the prior response's nextBefore.
var before int64
if v := r.URL.Query().Get("before"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
before = n
}
}
ctx, cancel := context.WithTimeout(r.Context(), cloudSearchTimeout)
defer cancel()
result, err := h.config.Cloud.SearchLogs(ctx, q, limit, hostID, containerID, before)
if err != nil {
if errors.Is(err, cloud.ErrNotConfigured) {
writeError(w, http.StatusServiceUnavailable, "cloud not configured")
return
}
if errors.Is(err, context.DeadlineExceeded) {
writeError(w, http.StatusGatewayTimeout, "cloud search timed out")
return
}
log.Warn().Err(err).Msg("cloud search failed")
writeError(w, http.StatusBadGateway, "cloud search failed")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
+19 -2
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/cloud"
"github.com/amir20/dozzle/internal/container" "github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification" "github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher" "github.com/amir20/dozzle/internal/notification/dispatcher"
@@ -50,8 +51,23 @@ type Config struct {
DisableAvatars bool DisableAvatars bool
ReleaseCheckMode ReleaseCheckMode ReleaseCheckMode ReleaseCheckMode
Labels container.ContainerLabels Labels container.ContainerLabels
OnCloudSetup func() Cloud CloudHooks
OnCloudUpdate func() }
// CloudHooks bundles cloud-side callbacks the web layer invokes. Grouping
// them keeps Config and createServer signatures stable as more cloud RPCs
// land. Nil-valued fields mean "feature unavailable" — handlers should
// degrade gracefully (e.g. SearchLogs nil → 503).
type CloudHooks struct {
// OnSetup signals that cloud configuration has been (re)written and
// the client should reconnect / re-authenticate.
OnSetup func()
// OnUpdate is fired when cloud-affecting settings change (e.g.
// streamLogs toggle) so the existing connection can pick them up.
OnUpdate func()
// SearchLogs proxies a substring/word-filter query to Doligence Cloud
// over the authenticated gRPC connection. Nil when cloud is not wired.
SearchLogs func(ctx context.Context, query string, limit int32, hostID, containerID string, before int64) (*cloud.SearchLogResult, error)
} }
type Authorization struct { type Authorization struct {
@@ -192,6 +208,7 @@ func createRouter(h *handler) *chi.Mux {
// Cloud API // Cloud API
r.Get("/cloud/status", h.cloudStatus) r.Get("/cloud/status", h.cloudStatus)
r.Get("/cloud/search/logs", h.cloudSearchLogs)
r.Get("/cloud/config", h.cloudConfig) r.Get("/cloud/config", h.cloudConfig)
r.Patch("/cloud/config", h.updateCloudConfig) r.Patch("/cloud/config", h.updateCloudConfig)
r.Delete("/cloud/config", h.deleteCloudConfig) r.Delete("/cloud/config", h.deleteCloudConfig)
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: Opret din første advarsel create-alert: Opret din første advarsel
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Det gør jeg senere" later: "Det gør jeg senere"
cloud-search:
containers-section: "Containere"
search-logs-for: "Søg logs efter \"{query}\""
across-containers: "indekseret på tværs af alle dine containere"
connect-to-enable: "Tilslut Dozzle Cloud for at søge i logs"
enable-streaming-to-search: "Aktiver log-streaming til Cloud"
open-container: "åbn container"
search-logs-shortcut: "søg logs"
cloud-connected: "Cloud tilsluttet"
results-page-title: "Logsøgning"
no-results: "Ingen matchende loglinjer."
search-failed: "Cloud-søgning mislykkedes."
search-empty-prompt: "Skriv en forespørgsel for at søge i logs."
searching: "Søger…"
hits-count: "{n} resultater"
window-suffix: "i de seneste 14 dage"
hero-title-cloud: "Søg containere og logs"
hero-title-plain: "Søg containere"
hero-pill-indexed: "Cloud-indeks"
modal-placeholder-cloud: "Søg containere og logs…"
modal-placeholder-plain: "Søg containere…"
col-time: "Tid"
col-level: "Niveau"
col-container: "Container"
col-message: "Besked"
container-removed: "Containeren er slettet"
cta-settings: "Cloud-indstillinger"
container-removed-pill: "slettet"
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: Erste Benachrichtigung erstellen create-alert: Erste Benachrichtigung erstellen
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Das mache ich später" later: "Das mache ich später"
cloud-search:
containers-section: "Container"
search-logs-for: "Logs durchsuchen nach \"{query}\""
across-containers: "indexiert über alle deine Container"
connect-to-enable: "Dozzle Cloud verbinden, um Logs zu durchsuchen"
enable-streaming-to-search: "Log-Streaming zur Cloud aktivieren"
open-container: "Container öffnen"
search-logs-shortcut: "Logs durchsuchen"
cloud-connected: "Cloud verbunden"
results-page-title: "Log-Suche"
no-results: "Keine passenden Logzeilen."
search-failed: "Cloud-Suche fehlgeschlagen."
search-empty-prompt: "Suchanfrage eingeben, um Logs zu durchsuchen."
searching: "Suche läuft…"
hits-count: "{n} Treffer"
window-suffix: "in den letzten 14 Tagen"
hero-title-cloud: "Container und Logs durchsuchen"
hero-title-plain: "Container durchsuchen"
hero-pill-indexed: "Cloud-Index"
modal-placeholder-cloud: "Container und Logs durchsuchen…"
modal-placeholder-plain: "Container durchsuchen…"
col-time: "Zeit"
col-level: "Level"
col-container: "Container"
col-message: "Nachricht"
container-removed: "Container wurde gelöscht"
cta-settings: "Cloud-Einstellungen"
container-removed-pill: "gelöscht"
+28
View File
@@ -113,6 +113,34 @@ button:
placeholder: placeholder:
search-containers: Search containers (⌘ + k, ⌃k) search-containers: Search containers (⌘ + k, ⌃k)
search: Search search: Search
cloud-search:
containers-section: Containers
search-logs-for: Search logs for "{query}"
across-containers: indexed across all your containers
connect-to-enable: Connect Dozzle Cloud to search logs
enable-streaming-to-search: Enable Stream Logs to Cloud to search
open-container: open container
search-logs-shortcut: search logs
cloud-connected: Cloud connected
results-page-title: Logs search
no-results: No matching log lines.
search-failed: Cloud search failed.
search-empty-prompt: Type a query to search logs across your containers.
searching: Searching…
hits-count: "{n} hits"
window-suffix: in the last 14 days
hero-title-cloud: Search containers and logs
hero-title-plain: Search containers
hero-pill-indexed: Cloud index
modal-placeholder-cloud: Search containers and logs…
modal-placeholder-plain: Search containers…
col-time: Time
col-level: Level
col-container: Container
col-message: Message
container-removed: Container has been deleted
cta-settings: "Cloud settings"
container-removed-pill: removed
settings: settings:
help-support: > help-support: >
Please support Dozzle by donating or sponsoring us on GitHub. Your contributions help us improve Dozzle for Please support Dozzle by donating or sponsoring us on GitHub. Your contributions help us improve Dozzle for
+28
View File
@@ -106,6 +106,34 @@ button:
placeholder: placeholder:
search-containers: Buscar contenedores (⌘ + K, CTRL + K) search-containers: Buscar contenedores (⌘ + K, CTRL + K)
search: Buscar search: Buscar
cloud-search:
containers-section: "Contenedores"
search-logs-for: "Buscar logs por \"{query}\""
across-containers: "indexados en todos tus contenedores"
connect-to-enable: "Conecta Dozzle Cloud para buscar logs"
enable-streaming-to-search: "Activa el streaming de logs a Cloud"
open-container: "abrir contenedor"
search-logs-shortcut: "buscar logs"
cloud-connected: "Cloud conectado"
results-page-title: "Búsqueda de logs"
no-results: "No hay líneas de log coincidentes."
search-failed: "La búsqueda en Cloud falló."
search-empty-prompt: "Escribe una consulta para buscar en los logs."
searching: "Buscando…"
hits-count: "{n} resultados"
window-suffix: "en los últimos 14 días"
hero-title-cloud: "Buscar contenedores y logs"
hero-title-plain: "Buscar contenedores"
hero-pill-indexed: "Índice Cloud"
modal-placeholder-cloud: "Buscar contenedores y logs…"
modal-placeholder-plain: "Buscar contenedores…"
col-time: "Hora"
col-level: "Nivel"
col-container: "Contenedor"
col-message: "Mensaje"
container-removed: "El contenedor ha sido eliminado"
cta-settings: "Configuración de Cloud"
container-removed-pill: "eliminado"
settings: settings:
help-support: > help-support: >
Por favor, apoya a Dozzle donando o patrocinándonos en GitHub. Tus contribuciones nos ayudan a mejorar Dozzle para todos. ¡Gracias! 🙏🏼 Por favor, apoya a Dozzle donando o patrocinándonos en GitHub. Tus contribuciones nos ayudan a mejorar Dozzle para todos. ¡Gracias! 🙏🏼
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: Créer votre première alerte create-alert: Créer votre première alerte
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Je ferai ça plus tard" later: "Je ferai ça plus tard"
cloud-search:
containers-section: "Conteneurs"
search-logs-for: "Rechercher des logs pour \"{query}\""
across-containers: "indexés sur tous vos conteneurs"
connect-to-enable: "Connecter Dozzle Cloud pour rechercher des logs"
enable-streaming-to-search: "Activer le streaming des logs vers le Cloud"
open-container: "ouvrir le conteneur"
search-logs-shortcut: "rechercher des logs"
cloud-connected: "Cloud connecté"
results-page-title: "Recherche de logs"
no-results: "Aucune ligne de log ne correspond."
search-failed: "La recherche Cloud a échoué."
search-empty-prompt: "Tapez une requête pour rechercher dans les logs."
searching: "Recherche…"
hits-count: "{n} résultats"
window-suffix: "au cours des 14 derniers jours"
hero-title-cloud: "Rechercher conteneurs et logs"
hero-title-plain: "Rechercher des conteneurs"
hero-pill-indexed: "Index Cloud"
modal-placeholder-cloud: "Rechercher conteneurs et logs…"
modal-placeholder-plain: "Rechercher des conteneurs…"
col-time: "Heure"
col-level: "Niveau"
col-container: "Conteneur"
col-message: "Message"
container-removed: "Le conteneur a été supprimé"
cta-settings: "Paramètres Cloud"
container-removed-pill: "supprimé"
+28
View File
@@ -351,3 +351,31 @@ cloud:
create-alert: Buat peringatan pertama Anda create-alert: Buat peringatan pertama Anda
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Nanti saja" later: "Nanti saja"
cloud-search:
containers-section: "Kontainer"
search-logs-for: "Cari log untuk \"{query}\""
across-containers: "diindeks di semua kontainer Anda"
connect-to-enable: "Hubungkan Dozzle Cloud untuk mencari log"
enable-streaming-to-search: "Aktifkan streaming log ke Cloud"
open-container: "buka kontainer"
search-logs-shortcut: "cari log"
cloud-connected: "Cloud terhubung"
results-page-title: "Pencarian log"
no-results: "Tidak ada baris log yang cocok."
search-failed: "Pencarian Cloud gagal."
search-empty-prompt: "Ketik kueri untuk mencari di log."
searching: "Mencari…"
hits-count: "{n} hasil"
window-suffix: "dalam 14 hari terakhir"
hero-title-cloud: "Cari kontainer dan log"
hero-title-plain: "Cari kontainer"
hero-pill-indexed: "Indeks Cloud"
modal-placeholder-cloud: "Cari kontainer dan log…"
modal-placeholder-plain: "Cari kontainer…"
col-time: "Waktu"
col-level: "Level"
col-container: "Kontainer"
col-message: "Pesan"
container-removed: "Kontainer telah dihapus"
cta-settings: "Pengaturan Cloud"
container-removed-pill: "dihapus"
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: Crea il tuo primo avviso create-alert: Crea il tuo primo avviso
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Lo farò dopo" later: "Lo farò dopo"
cloud-search:
containers-section: "Container"
search-logs-for: "Cerca log per \"{query}\""
across-containers: "indicizzati su tutti i tuoi container"
connect-to-enable: "Connetti Dozzle Cloud per cercare nei log"
enable-streaming-to-search: "Abilita lo streaming dei log su Cloud"
open-container: "apri container"
search-logs-shortcut: "cerca log"
cloud-connected: "Cloud connesso"
results-page-title: "Ricerca log"
no-results: "Nessuna riga di log corrispondente."
search-failed: "Ricerca Cloud fallita."
search-empty-prompt: "Digita una query per cercare nei log."
searching: "Ricerca…"
hits-count: "{n} risultati"
window-suffix: "negli ultimi 14 giorni"
hero-title-cloud: "Cerca container e log"
hero-title-plain: "Cerca container"
hero-pill-indexed: "Indice Cloud"
modal-placeholder-cloud: "Cerca container e log…"
modal-placeholder-plain: "Cerca container…"
col-time: "Ora"
col-level: "Livello"
col-container: "Container"
col-message: "Messaggio"
container-removed: "Il container è stato eliminato"
cta-settings: "Impostazioni Cloud"
container-removed-pill: "eliminato"
+28
View File
@@ -342,3 +342,31 @@ cloud:
create-alert: 첫 번째 알림 만들기 create-alert: 첫 번째 알림 만들기
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "나중에 할게요" later: "나중에 할게요"
cloud-search:
containers-section: "컨테이너"
search-logs-for: "\"{query}\" 로그 검색"
across-containers: "모든 컨테이너에서 색인됨"
connect-to-enable: "로그를 검색하려면 Dozzle Cloud에 연결하세요"
enable-streaming-to-search: "Cloud로 로그 스트리밍 활성화"
open-container: "컨테이너 열기"
search-logs-shortcut: "로그 검색"
cloud-connected: "Cloud 연결됨"
results-page-title: "로그 검색"
no-results: "일치하는 로그 라인이 없습니다."
search-failed: "Cloud 검색 실패."
search-empty-prompt: "로그를 검색하려면 쿼리를 입력하세요."
searching: "검색 중…"
hits-count: "{n}개 결과"
window-suffix: "최근 14일 동안"
hero-title-cloud: "컨테이너 및 로그 검색"
hero-title-plain: "컨테이너 검색"
hero-pill-indexed: "Cloud 인덱스"
modal-placeholder-cloud: "컨테이너 및 로그 검색…"
modal-placeholder-plain: "컨테이너 검색…"
col-time: "시간"
col-level: "레벨"
col-container: "컨테이너"
col-message: "메시지"
container-removed: "컨테이너가 삭제되었습니다"
cta-settings: "Cloud 설정"
container-removed-pill: "삭제됨"
+28
View File
@@ -340,3 +340,31 @@ cloud:
create-alert: Maak je eerste melding create-alert: Maak je eerste melding
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Dat doe ik later" later: "Dat doe ik later"
cloud-search:
containers-section: "Containers"
search-logs-for: "Logs zoeken op \"{query}\""
across-containers: "geïndexeerd over al je containers"
connect-to-enable: "Dozzle Cloud verbinden om logs te zoeken"
enable-streaming-to-search: "Schakel logstreaming naar Cloud in"
open-container: "container openen"
search-logs-shortcut: "logs zoeken"
cloud-connected: "Cloud verbonden"
results-page-title: "Logs doorzoeken"
no-results: "Geen overeenkomende logregels."
search-failed: "Cloud-zoekopdracht mislukt."
search-empty-prompt: "Typ een zoekopdracht om logs te doorzoeken."
searching: "Zoeken…"
hits-count: "{n} treffers"
window-suffix: "in de afgelopen 14 dagen"
hero-title-cloud: "Containers en logs zoeken"
hero-title-plain: "Containers zoeken"
hero-pill-indexed: "Cloud-index"
modal-placeholder-cloud: "Containers en logs zoeken…"
modal-placeholder-plain: "Containers zoeken…"
col-time: "Tijd"
col-level: "Niveau"
col-container: "Container"
col-message: "Bericht"
container-removed: "Container is verwijderd"
cta-settings: "Cloud-instellingen"
container-removed-pill: "verwijderd"
+28
View File
@@ -346,3 +346,31 @@ cloud:
create-alert: Utwórz pierwszy alert create-alert: Utwórz pierwszy alert
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Zrobię to później" later: "Zrobię to później"
cloud-search:
containers-section: "Kontenery"
search-logs-for: "Przeszukaj logi pod kątem \"{query}\""
across-containers: "zindeksowane we wszystkich Twoich kontenerach"
connect-to-enable: "Połącz Dozzle Cloud, aby przeszukiwać logi"
enable-streaming-to-search: "Włącz przesyłanie logów do Cloud"
open-container: "otwórz kontener"
search-logs-shortcut: "przeszukaj logi"
cloud-connected: "Cloud połączony"
results-page-title: "Wyszukiwanie logów"
no-results: "Brak pasujących linii logu."
search-failed: "Wyszukiwanie Cloud nie powiodło się."
search-empty-prompt: "Wpisz zapytanie, aby przeszukać logi."
searching: "Wyszukiwanie…"
hits-count: "{n} wyników"
window-suffix: "w ciągu ostatnich 14 dni"
hero-title-cloud: "Szukaj kontenerów i logów"
hero-title-plain: "Szukaj kontenerów"
hero-pill-indexed: "Indeks Cloud"
modal-placeholder-cloud: "Szukaj kontenerów i logów…"
modal-placeholder-plain: "Szukaj kontenerów…"
col-time: "Czas"
col-level: "Poziom"
col-container: "Kontener"
col-message: "Wiadomość"
container-removed: "Kontener został usunięty"
cta-settings: "Ustawienia Cloud"
container-removed-pill: "usunięty"
+28
View File
@@ -348,3 +348,31 @@ cloud:
create-alert: Create Yer First Alert create-alert: Create Yer First Alert
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "I'll do this later, arrr" later: "I'll do this later, arrr"
cloud-search:
containers-section: "Contentores"
search-logs-for: "Pesquisar logs por \"{query}\""
across-containers: "indexados em todos os seus contentores"
connect-to-enable: "Ligar o Dozzle Cloud para pesquisar logs"
enable-streaming-to-search: "Ativar streaming de logs para a Cloud"
open-container: "abrir contentor"
search-logs-shortcut: "pesquisar logs"
cloud-connected: "Cloud ligada"
results-page-title: "Pesquisa de logs"
no-results: "Nenhuma linha de log corresponde."
search-failed: "Falha na pesquisa Cloud."
search-empty-prompt: "Escreva uma consulta para pesquisar nos logs."
searching: "A pesquisar…"
hits-count: "{n} resultados"
window-suffix: "nos últimos 14 dias"
hero-title-cloud: "Pesquisar contentores e logs"
hero-title-plain: "Pesquisar contentores"
hero-pill-indexed: "Índice Cloud"
modal-placeholder-cloud: "Pesquisar contentores e logs…"
modal-placeholder-plain: "Pesquisar contentores…"
col-time: "Hora"
col-level: "Nível"
col-container: "Contentor"
col-message: "Mensagem"
container-removed: "O contentor foi eliminado"
cta-settings: "Definições da Cloud"
container-removed-pill: "removido"
+28
View File
@@ -338,3 +338,31 @@ cloud:
create-alert: Crie seu primeiro alerta create-alert: Crie seu primeiro alerta
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Farei isso depois" later: "Farei isso depois"
cloud-search:
containers-section: "Containers"
search-logs-for: "Buscar logs por \"{query}\""
across-containers: "indexados em todos os seus containers"
connect-to-enable: "Conectar Dozzle Cloud para buscar logs"
enable-streaming-to-search: "Ativar streaming de logs para a Cloud"
open-container: "abrir container"
search-logs-shortcut: "buscar logs"
cloud-connected: "Cloud conectado"
results-page-title: "Busca de logs"
no-results: "Nenhuma linha de log correspondente."
search-failed: "Falha na busca da Cloud."
search-empty-prompt: "Digite uma consulta para buscar nos logs."
searching: "Buscando…"
hits-count: "{n} resultados"
window-suffix: "nos últimos 14 dias"
hero-title-cloud: "Buscar containers e logs"
hero-title-plain: "Buscar containers"
hero-pill-indexed: "Índice Cloud"
modal-placeholder-cloud: "Buscar containers e logs…"
modal-placeholder-plain: "Buscar containers…"
col-time: "Hora"
col-level: "Nível"
col-container: "Container"
col-message: "Mensagem"
container-removed: "O container foi excluído"
cta-settings: "Configurações da Cloud"
container-removed-pill: "removido"
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: Создайте первое оповещение create-alert: Создайте первое оповещение
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Сделаю это позже" later: "Сделаю это позже"
cloud-search:
containers-section: "Контейнеры"
search-logs-for: "Поиск логов по \"{query}\""
across-containers: "проиндексированных по всем вашим контейнерам"
connect-to-enable: "Подключите Dozzle Cloud, чтобы искать в логах"
enable-streaming-to-search: "Включите стриминг логов в Cloud"
open-container: "открыть контейнер"
search-logs-shortcut: "поиск логов"
cloud-connected: "Cloud подключён"
results-page-title: "Поиск по логам"
no-results: "Совпадений в логах не найдено."
search-failed: "Поиск в Cloud не удался."
search-empty-prompt: "Введите запрос для поиска по логам."
searching: "Поиск…"
hits-count: "{n} результатов"
window-suffix: "за последние 14 дней"
hero-title-cloud: "Поиск по контейнерам и логам"
hero-title-plain: "Поиск по контейнерам"
hero-pill-indexed: "Индекс Cloud"
modal-placeholder-cloud: "Поиск по контейнерам и логам…"
modal-placeholder-plain: "Поиск по контейнерам…"
col-time: "Время"
col-level: "Уровень"
col-container: "Контейнер"
col-message: "Сообщение"
container-removed: "Контейнер был удалён"
cta-settings: "Настройки Cloud"
container-removed-pill: "удалён"
+28
View File
@@ -344,3 +344,31 @@ cloud:
create-alert: Ustvarite prvo opozorilo create-alert: Ustvarite prvo opozorilo
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "To bom naredil pozneje" later: "To bom naredil pozneje"
cloud-search:
containers-section: "Vsebniki"
search-logs-for: "Iskanje dnevnikov za \"{query}\""
across-containers: "indeksirano po vseh vaših vsebnikih"
connect-to-enable: "Povežite Dozzle Cloud za iskanje po dnevnikih"
enable-streaming-to-search: "Omogoči pretakanje dnevnikov v Cloud"
open-container: "odpri vsebnik"
search-logs-shortcut: "išči po dnevnikih"
cloud-connected: "Cloud povezan"
results-page-title: "Iskanje dnevnikov"
no-results: "Ni ujemajočih se vrstic dnevnika."
search-failed: "Iskanje v Cloudu ni uspelo."
search-empty-prompt: "Vpišite poizvedbo za iskanje po dnevnikih."
searching: "Iskanje…"
hits-count: "{n} zadetkov"
window-suffix: "v zadnjih 14 dneh"
hero-title-cloud: "Iskanje vsebnikov in dnevnikov"
hero-title-plain: "Iskanje vsebnikov"
hero-pill-indexed: "Cloud indeks"
modal-placeholder-cloud: "Iskanje vsebnikov in dnevnikov…"
modal-placeholder-plain: "Iskanje vsebnikov…"
col-time: "Čas"
col-level: "Raven"
col-container: "Vsebnik"
col-message: "Sporočilo"
container-removed: "Vsebnik je bil izbrisan"
cta-settings: "Nastavitve Cloud"
container-removed-pill: "izbrisano"
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: İlk uyarınızı oluşturun create-alert: İlk uyarınızı oluşturun
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "Bunu daha sonra yapacağım" later: "Bunu daha sonra yapacağım"
cloud-search:
containers-section: "Konteynerler"
search-logs-for: "\"{query}\" için logları ara"
across-containers: "tüm konteynerleriniz arasında indekslenmiş"
connect-to-enable: "Logları aramak için Dozzle Cloud'a bağlanın"
enable-streaming-to-search: "Log akışını Cloud'a etkinleştirin"
open-container: "konteyneri aç"
search-logs-shortcut: "logları ara"
cloud-connected: "Cloud bağlı"
results-page-title: "Log araması"
no-results: "Eşleşen log satırı yok."
search-failed: "Cloud araması başarısız oldu."
search-empty-prompt: "Loglarda arama yapmak için bir sorgu yazın."
searching: "Aranıyor…"
hits-count: "{n} sonuç"
window-suffix: "son 14 gün içinde"
hero-title-cloud: "Konteynerleri ve logları ara"
hero-title-plain: "Konteynerleri ara"
hero-pill-indexed: "Cloud indeksi"
modal-placeholder-cloud: "Konteynerleri ve logları ara…"
modal-placeholder-plain: "Konteynerleri ara…"
col-time: "Zaman"
col-level: "Seviye"
col-container: "Konteyner"
col-message: "Mesaj"
container-removed: "Konteyner silindi"
cta-settings: "Cloud ayarları"
container-removed-pill: "silindi"
+28
View File
@@ -342,3 +342,31 @@ cloud:
create-alert: 建立您的第一個警示 create-alert: 建立您的第一個警示
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "稍後再說" later: "稍後再說"
cloud-search:
containers-section: "容器"
search-logs-for: "搜尋日誌 \"{query}\""
across-containers: "已在所有容器中建立索引"
connect-to-enable: "連接 Dozzle Cloud 以搜尋日誌"
enable-streaming-to-search: "啟用日誌串流到 Cloud"
open-container: "打開容器"
search-logs-shortcut: "搜尋日誌"
cloud-connected: "Cloud 已連接"
results-page-title: "日誌搜尋"
no-results: "沒有匹配的日誌行。"
search-failed: "Cloud 搜尋失敗。"
search-empty-prompt: "輸入查詢以搜尋日誌。"
searching: "搜尋中…"
hits-count: "{n} 條結果"
window-suffix: "在過去 14 天內"
hero-title-cloud: "搜尋容器和日誌"
hero-title-plain: "搜尋容器"
hero-pill-indexed: "Cloud 索引"
modal-placeholder-cloud: "搜尋容器和日誌…"
modal-placeholder-plain: "搜尋容器…"
col-time: "時間"
col-level: "級別"
col-container: "容器"
col-message: "訊息"
container-removed: "容器已被刪除"
cta-settings: "Cloud 設定"
container-removed-pill: "已刪除"
+28
View File
@@ -339,3 +339,31 @@ cloud:
create-alert: 创建您的第一个警报 create-alert: 创建您的第一个警报
default-alert-name: Container exited with error default-alert-name: Container exited with error
later: "稍后再说" later: "稍后再说"
cloud-search:
containers-section: "容器"
search-logs-for: "搜索日志 \"{query}\""
across-containers: "已在所有容器中建立索引"
connect-to-enable: "连接 Dozzle Cloud 以搜索日志"
enable-streaming-to-search: "启用日志流式传输到 Cloud"
open-container: "打开容器"
search-logs-shortcut: "搜索日志"
cloud-connected: "Cloud 已连接"
results-page-title: "日志搜索"
no-results: "没有匹配的日志行。"
search-failed: "Cloud 搜索失败。"
search-empty-prompt: "输入查询以搜索日志。"
searching: "搜索中…"
hits-count: "{n} 条结果"
window-suffix: "在过去 14 天内"
hero-title-cloud: "搜索容器和日志"
hero-title-plain: "搜索容器"
hero-pill-indexed: "Cloud 索引"
modal-placeholder-cloud: "搜索容器和日志…"
modal-placeholder-plain: "搜索容器…"
col-time: "时间"
col-level: "级别"
col-container: "容器"
col-message: "消息"
container-removed: "容器已被删除"
cta-settings: "Cloud 设置"
container-removed-pill: "已删除"
+7 -4
View File
@@ -186,7 +186,11 @@ func main() {
cloudClient.Notify() cloudClient.Notify()
} }
srv := createServer(args, hostService, cloudClient.Notify, cloudClient.Reconnect) srv := createServer(args, hostService, web.CloudHooks{
OnSetup: cloudClient.Notify,
OnUpdate: cloudClient.Reconnect,
SearchLogs: cloudClient.SearchLogs,
})
go func() { go func() {
log.Info().Msgf("Accepting connections on %s", args.Addr) log.Info().Msgf("Accepting connections on %s", args.Addr)
@@ -214,7 +218,7 @@ func fileExists(filename string) bool {
return err == nil return err == nil
} }
func createServer(args cli.Args, hostService web.HostService, onCloudSetup func(), onCloudUpdate func()) *http.Server { func createServer(args cli.Args, hostService web.HostService, cloudHooks web.CloudHooks) *http.Server {
_, dev := os.LookupEnv("DEV") _, dev := os.LookupEnv("DEV")
var releaseCheckMode web.ReleaseCheckMode = web.Automatic var releaseCheckMode web.ReleaseCheckMode = web.Automatic
@@ -293,8 +297,7 @@ func createServer(args cli.Args, hostService web.HostService, onCloudSetup func(
DisableAvatars: args.DisableAvatars, DisableAvatars: args.DisableAvatars,
ReleaseCheckMode: releaseCheckMode, ReleaseCheckMode: releaseCheckMode,
Labels: args.Filter, Labels: args.Filter,
OnCloudSetup: onCloudSetup, Cloud: cloudHooks,
OnCloudUpdate: onCloudUpdate,
} }
assets, err := fs.Sub(content, "dist") assets, err := fs.Sub(content, "dist")
+308 -16
View File
@@ -353,6 +353,12 @@ type LogBatchEntry struct {
Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"`
Stream string `protobuf:"bytes,6,opt,name=stream,proto3" json:"stream,omitempty"` // "stdout" or "stderr" Stream string `protobuf:"bytes,6,opt,name=stream,proto3" json:"stream,omitempty"` // "stdout" or "stderr"
Level string `protobuf:"bytes,7,opt,name=level,proto3" json:"level,omitempty"` // "info", "warn", "error", etc. (best-effort) Level string `protobuf:"bytes,7,opt,name=level,proto3" json:"level,omitempty"` // "info", "warn", "error", etc. (best-effort)
// Deterministic FNV-32a hash of the raw log line, the same id Dozzle
// stamps on LogEvent.Id. Cloud indexes this as a non-stream field so
// search results can produce a stable permanent link
// (/container/:id/time/:datetime?logId=...) that lands the user on
// exactly the matching line in the local log viewer.
LogId uint32 `protobuf:"varint,8,opt,name=log_id,json=logId,proto3" json:"log_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -436,6 +442,13 @@ func (x *LogBatchEntry) GetLevel() string {
return "" return ""
} }
func (x *LogBatchEntry) GetLogId() uint32 {
if x != nil {
return x.LogId
}
return 0
}
type ListToolsRequest struct { type ListToolsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@@ -1910,6 +1923,256 @@ func (x *NotificationResult) GetMessage() string {
return "" return ""
} }
// Cloud log search.
type SearchLogsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Substring/word-filter query. Empty -> empty result. Whitespace-only
// is rejected client-side; server treats as empty.
Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"`
// Result cap. Default 20, server-capped at 50.
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
// Pagination cursor: return only hits with timestamp_ns < this value.
// 0 = newest. Reserved for future use.
BeforeTsNs int64 `protobuf:"varint,3,opt,name=before_ts_ns,json=beforeTsNs,proto3" json:"before_ts_ns,omitempty"`
// Optional filter — narrow to a specific Docker host inside the instance.
// Empty = all hosts under this instance.
HostId string `protobuf:"bytes,4,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"`
// Optional filter — narrow to a specific container.
ContainerId string `protobuf:"bytes,5,opt,name=container_id,json=containerId,proto3" json:"container_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SearchLogsRequest) Reset() {
*x = SearchLogsRequest{}
mi := &file_cloud_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SearchLogsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchLogsRequest) ProtoMessage() {}
func (x *SearchLogsRequest) ProtoReflect() protoreflect.Message {
mi := &file_cloud_proto_msgTypes[22]
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 SearchLogsRequest.ProtoReflect.Descriptor instead.
func (*SearchLogsRequest) Descriptor() ([]byte, []int) {
return file_cloud_proto_rawDescGZIP(), []int{22}
}
func (x *SearchLogsRequest) GetQuery() string {
if x != nil {
return x.Query
}
return ""
}
func (x *SearchLogsRequest) GetLimit() int32 {
if x != nil {
return x.Limit
}
return 0
}
func (x *SearchLogsRequest) GetBeforeTsNs() int64 {
if x != nil {
return x.BeforeTsNs
}
return 0
}
func (x *SearchLogsRequest) GetHostId() string {
if x != nil {
return x.HostId
}
return ""
}
func (x *SearchLogsRequest) GetContainerId() string {
if x != nil {
return x.ContainerId
}
return ""
}
type SearchLogsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hits []*SearchLogHit `protobuf:"bytes,1,rep,name=hits,proto3" json:"hits,omitempty"`
HasMore bool `protobuf:"varint,2,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
// For pagination: pass back as before_ts_ns to fetch the next page.
NextBeforeTsNs int64 `protobuf:"varint,3,opt,name=next_before_ts_ns,json=nextBeforeTsNs,proto3" json:"next_before_ts_ns,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SearchLogsResponse) Reset() {
*x = SearchLogsResponse{}
mi := &file_cloud_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SearchLogsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchLogsResponse) ProtoMessage() {}
func (x *SearchLogsResponse) ProtoReflect() protoreflect.Message {
mi := &file_cloud_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 SearchLogsResponse.ProtoReflect.Descriptor instead.
func (*SearchLogsResponse) Descriptor() ([]byte, []int) {
return file_cloud_proto_rawDescGZIP(), []int{23}
}
func (x *SearchLogsResponse) GetHits() []*SearchLogHit {
if x != nil {
return x.Hits
}
return nil
}
func (x *SearchLogsResponse) GetHasMore() bool {
if x != nil {
return x.HasMore
}
return false
}
func (x *SearchLogsResponse) GetNextBeforeTsNs() int64 {
if x != nil {
return x.NextBeforeTsNs
}
return 0
}
type SearchLogHit struct {
state protoimpl.MessageState `protogen:"open.v1"`
TimestampNs int64 `protobuf:"varint,1,opt,name=timestamp_ns,json=timestampNs,proto3" json:"timestamp_ns,omitempty"`
HostId string `protobuf:"bytes,2,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"`
ContainerId string `protobuf:"bytes,3,opt,name=container_id,json=containerId,proto3" json:"container_id,omitempty"`
ContainerName string `protobuf:"bytes,4,opt,name=container_name,json=containerName,proto3" json:"container_name,omitempty"`
// Full log line as indexed.
Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"`
Stream string `protobuf:"bytes,6,opt,name=stream,proto3" json:"stream,omitempty"`
Level string `protobuf:"bytes,7,opt,name=level,proto3" json:"level,omitempty"`
// FNV-32a hash of the raw log line — same id Dozzle assigns LogEvent.Id
// and exposes via "Copy permalink". Lets the search-result row deep-link
// straight to the matching line.
LogId uint32 `protobuf:"varint,8,opt,name=log_id,json=logId,proto3" json:"log_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SearchLogHit) Reset() {
*x = SearchLogHit{}
mi := &file_cloud_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SearchLogHit) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchLogHit) ProtoMessage() {}
func (x *SearchLogHit) ProtoReflect() protoreflect.Message {
mi := &file_cloud_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 SearchLogHit.ProtoReflect.Descriptor instead.
func (*SearchLogHit) Descriptor() ([]byte, []int) {
return file_cloud_proto_rawDescGZIP(), []int{24}
}
func (x *SearchLogHit) GetTimestampNs() int64 {
if x != nil {
return x.TimestampNs
}
return 0
}
func (x *SearchLogHit) GetHostId() string {
if x != nil {
return x.HostId
}
return ""
}
func (x *SearchLogHit) GetContainerId() string {
if x != nil {
return x.ContainerId
}
return ""
}
func (x *SearchLogHit) GetContainerName() string {
if x != nil {
return x.ContainerName
}
return ""
}
func (x *SearchLogHit) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *SearchLogHit) GetStream() string {
if x != nil {
return x.Stream
}
return ""
}
func (x *SearchLogHit) GetLevel() string {
if x != nil {
return x.Level
}
return ""
}
func (x *SearchLogHit) GetLogId() uint32 {
if x != nil {
return x.LogId
}
return 0
}
var File_cloud_proto protoreflect.FileDescriptor var File_cloud_proto protoreflect.FileDescriptor
const file_cloud_proto_rawDesc = "" + const file_cloud_proto_rawDesc = "" +
@@ -1932,7 +2195,7 @@ const file_cloud_proto_rawDesc = "" +
"\tlog_batch\x18\x04 \x01(\v2\x0f.cloud.LogBatchH\x00R\blogBatchB\x06\n" + "\tlog_batch\x18\x04 \x01(\v2\x0f.cloud.LogBatchH\x00R\blogBatchB\x06\n" +
"\x04type\":\n" + "\x04type\":\n" +
"\bLogBatch\x12.\n" + "\bLogBatch\x12.\n" +
"\aentries\x18\x01 \x03(\v2\x14.cloud.LogBatchEntryR\aentries\"\xdd\x01\n" + "\aentries\x18\x01 \x03(\v2\x14.cloud.LogBatchEntryR\aentries\"\xf4\x01\n" +
"\rLogBatchEntry\x12\x17\n" + "\rLogBatchEntry\x12\x17\n" +
"\ahost_id\x18\x01 \x01(\tR\x06hostId\x12!\n" + "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12!\n" +
"\fcontainer_id\x18\x02 \x01(\tR\vcontainerId\x12%\n" + "\fcontainer_id\x18\x02 \x01(\tR\vcontainerId\x12%\n" +
@@ -1940,7 +2203,8 @@ const file_cloud_proto_rawDesc = "" +
"\ftimestamp_ns\x18\x04 \x01(\x03R\vtimestampNs\x12\x18\n" + "\ftimestamp_ns\x18\x04 \x01(\x03R\vtimestampNs\x12\x18\n" +
"\amessage\x18\x05 \x01(\tR\amessage\x12\x16\n" + "\amessage\x18\x05 \x01(\tR\amessage\x12\x16\n" +
"\x06stream\x18\x06 \x01(\tR\x06stream\x12\x14\n" + "\x06stream\x18\x06 \x01(\tR\x06stream\x12\x14\n" +
"\x05level\x18\a \x01(\tR\x05level\"\x12\n" + "\x05level\x18\a \x01(\tR\x05level\x12\x15\n" +
"\x06log_id\x18\b \x01(\rR\x05logId\"\x12\n" +
"\x10ListToolsRequest\"Z\n" + "\x10ListToolsRequest\"Z\n" +
"\x11ListToolsResponse\x12+\n" + "\x11ListToolsResponse\x12+\n" +
"\x05tools\x18\x01 \x03(\v2\x15.cloud.ToolDefinitionR\x05tools\x12\x18\n" + "\x05tools\x18\x01 \x03(\v2\x15.cloud.ToolDefinitionR\x05tools\x12\x18\n" +
@@ -2068,15 +2332,37 @@ const file_cloud_proto_rawDesc = "" +
"\amessage\x18\x03 \x01(\tR\amessage\"H\n" + "\amessage\x18\x03 \x01(\tR\amessage\"H\n" +
"\x12NotificationResult\x12\x18\n" + "\x12NotificationResult\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage*o\n" + "\amessage\x18\x02 \x01(\tR\amessage\"\x9d\x01\n" +
"\x11SearchLogsRequest\x12\x14\n" +
"\x05query\x18\x01 \x01(\tR\x05query\x12\x14\n" +
"\x05limit\x18\x02 \x01(\x05R\x05limit\x12 \n" +
"\fbefore_ts_ns\x18\x03 \x01(\x03R\n" +
"beforeTsNs\x12\x17\n" +
"\ahost_id\x18\x04 \x01(\tR\x06hostId\x12!\n" +
"\fcontainer_id\x18\x05 \x01(\tR\vcontainerId\"\x83\x01\n" +
"\x12SearchLogsResponse\x12'\n" +
"\x04hits\x18\x01 \x03(\v2\x13.cloud.SearchLogHitR\x04hits\x12\x19\n" +
"\bhas_more\x18\x02 \x01(\bR\ahasMore\x12)\n" +
"\x11next_before_ts_ns\x18\x03 \x01(\x03R\x0enextBeforeTsNs\"\xf3\x01\n" +
"\fSearchLogHit\x12!\n" +
"\ftimestamp_ns\x18\x01 \x01(\x03R\vtimestampNs\x12\x17\n" +
"\ahost_id\x18\x02 \x01(\tR\x06hostId\x12!\n" +
"\fcontainer_id\x18\x03 \x01(\tR\vcontainerId\x12%\n" +
"\x0econtainer_name\x18\x04 \x01(\tR\rcontainerName\x12\x18\n" +
"\amessage\x18\x05 \x01(\tR\amessage\x12\x16\n" +
"\x06stream\x18\x06 \x01(\tR\x06stream\x12\x14\n" +
"\x05level\x18\a \x01(\tR\x05level\x12\x15\n" +
"\x06log_id\x18\b \x01(\rR\x05logId*o\n" +
"\tToolScope\x12\x1a\n" + "\tToolScope\x12\x1a\n" +
"\x16TOOL_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x16TOOL_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" +
"\x13TOOL_SCOPE_INSTANCE\x10\x01\x12\x13\n" + "\x13TOOL_SCOPE_INSTANCE\x10\x01\x12\x13\n" +
"\x0fTOOL_SCOPE_HOST\x10\x02\x12\x18\n" + "\x0fTOOL_SCOPE_HOST\x10\x02\x12\x18\n" +
"\x14TOOL_SCOPE_CONTAINER\x10\x032M\n" + "\x14TOOL_SCOPE_CONTAINER\x10\x032\x90\x01\n" +
"\x10CloudToolService\x129\n" + "\x10CloudToolService\x129\n" +
"\n" + "\n" +
"ToolStream\x12\x13.cloud.ToolResponse\x1a\x12.cloud.ToolRequest(\x010\x01B&Z$github.com/amir20/dozzle/proto/cloudb\x06proto3" "ToolStream\x12\x13.cloud.ToolResponse\x1a\x12.cloud.ToolRequest(\x010\x01\x12A\n" +
"\n" +
"SearchLogs\x12\x18.cloud.SearchLogsRequest\x1a\x19.cloud.SearchLogsResponseB&Z$github.com/amir20/dozzle/proto/cloudb\x06proto3"
var ( var (
file_cloud_proto_rawDescOnce sync.Once file_cloud_proto_rawDescOnce sync.Once
@@ -2091,7 +2377,7 @@ func file_cloud_proto_rawDescGZIP() []byte {
} }
var file_cloud_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_cloud_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_cloud_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_cloud_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
var file_cloud_proto_goTypes = []any{ var file_cloud_proto_goTypes = []any{
(ToolScope)(0), // 0: cloud.ToolScope (ToolScope)(0), // 0: cloud.ToolScope
(*ToolRequest)(nil), // 1: cloud.ToolRequest (*ToolRequest)(nil), // 1: cloud.ToolRequest
@@ -2116,7 +2402,10 @@ var file_cloud_proto_goTypes = []any{
(*ActionResult)(nil), // 20: cloud.ActionResult (*ActionResult)(nil), // 20: cloud.ActionResult
(*DeployResult)(nil), // 21: cloud.DeployResult (*DeployResult)(nil), // 21: cloud.DeployResult
(*NotificationResult)(nil), // 22: cloud.NotificationResult (*NotificationResult)(nil), // 22: cloud.NotificationResult
nil, // 23: cloud.InspectContainerResult.LabelsEntry (*SearchLogsRequest)(nil), // 23: cloud.SearchLogsRequest
(*SearchLogsResponse)(nil), // 24: cloud.SearchLogsResponse
(*SearchLogHit)(nil), // 25: cloud.SearchLogHit
nil, // 26: cloud.InspectContainerResult.LabelsEntry
} }
var file_cloud_proto_depIdxs = []int32{ var file_cloud_proto_depIdxs = []int32{
5, // 0: cloud.ToolRequest.list_tools:type_name -> cloud.ListToolsRequest 5, // 0: cloud.ToolRequest.list_tools:type_name -> cloud.ListToolsRequest
@@ -2140,14 +2429,17 @@ var file_cloud_proto_depIdxs = []int32{
13, // 18: cloud.ListContainersResult.containers:type_name -> cloud.ContainerInfo 13, // 18: cloud.ListContainersResult.containers:type_name -> cloud.ContainerInfo
15, // 19: cloud.ContainerStatsResult.stats:type_name -> cloud.ContainerStatEntry 15, // 19: cloud.ContainerStatsResult.stats:type_name -> cloud.ContainerStatEntry
17, // 20: cloud.FetchLogsResult.entries:type_name -> cloud.LogEntry 17, // 20: cloud.FetchLogsResult.entries:type_name -> cloud.LogEntry
23, // 21: cloud.InspectContainerResult.labels:type_name -> cloud.InspectContainerResult.LabelsEntry 26, // 21: cloud.InspectContainerResult.labels:type_name -> cloud.InspectContainerResult.LabelsEntry
2, // 22: cloud.CloudToolService.ToolStream:input_type -> cloud.ToolResponse 25, // 22: cloud.SearchLogsResponse.hits:type_name -> cloud.SearchLogHit
1, // 23: cloud.CloudToolService.ToolStream:output_type -> cloud.ToolRequest 2, // 23: cloud.CloudToolService.ToolStream:input_type -> cloud.ToolResponse
23, // [23:24] is the sub-list for method output_type 23, // 24: cloud.CloudToolService.SearchLogs:input_type -> cloud.SearchLogsRequest
22, // [22:23] is the sub-list for method input_type 1, // 25: cloud.CloudToolService.ToolStream:output_type -> cloud.ToolRequest
22, // [22:22] is the sub-list for extension type_name 24, // 26: cloud.CloudToolService.SearchLogs:output_type -> cloud.SearchLogsResponse
22, // [22:22] is the sub-list for extension extendee 25, // [25:27] is the sub-list for method output_type
0, // [0:22] is the sub-list for field type_name 23, // [23:25] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
} }
func init() { file_cloud_proto_init() } func init() { file_cloud_proto_init() }
@@ -2181,7 +2473,7 @@ func file_cloud_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cloud_proto_rawDesc), len(file_cloud_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cloud_proto_rawDesc), len(file_cloud_proto_rawDesc)),
NumEnums: 1, NumEnums: 1,
NumMessages: 23, NumMessages: 26,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
+48 -1
View File
@@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
CloudToolService_ToolStream_FullMethodName = "/cloud.CloudToolService/ToolStream" CloudToolService_ToolStream_FullMethodName = "/cloud.CloudToolService/ToolStream"
CloudToolService_SearchLogs_FullMethodName = "/cloud.CloudToolService/SearchLogs"
) )
// CloudToolServiceClient is the client API for CloudToolService service. // CloudToolServiceClient is the client API for CloudToolService service.
@@ -28,6 +29,11 @@ const (
type CloudToolServiceClient interface { type CloudToolServiceClient interface {
// Dozzle sends ToolResponse, cloud sends ToolRequest // Dozzle sends ToolResponse, cloud sends ToolRequest
ToolStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ToolResponse, ToolRequest], error) ToolStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ToolResponse, ToolRequest], error)
// Dozzle-initiated unary call: search log lines this instance has streamed
// to Cloud (gated by streamLogs opt-in). Cloud scopes the query server-side
// to the (user_id, api_key_id) derived from the authenticated connection;
// Dozzle does NOT pass any identity fields in the request.
SearchLogs(ctx context.Context, in *SearchLogsRequest, opts ...grpc.CallOption) (*SearchLogsResponse, error)
} }
type cloudToolServiceClient struct { type cloudToolServiceClient struct {
@@ -51,12 +57,27 @@ func (c *cloudToolServiceClient) ToolStream(ctx context.Context, opts ...grpc.Ca
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type CloudToolService_ToolStreamClient = grpc.BidiStreamingClient[ToolResponse, ToolRequest] type CloudToolService_ToolStreamClient = grpc.BidiStreamingClient[ToolResponse, ToolRequest]
func (c *cloudToolServiceClient) SearchLogs(ctx context.Context, in *SearchLogsRequest, opts ...grpc.CallOption) (*SearchLogsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SearchLogsResponse)
err := c.cc.Invoke(ctx, CloudToolService_SearchLogs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CloudToolServiceServer is the server API for CloudToolService service. // CloudToolServiceServer is the server API for CloudToolService service.
// All implementations must embed UnimplementedCloudToolServiceServer // All implementations must embed UnimplementedCloudToolServiceServer
// for forward compatibility. // for forward compatibility.
type CloudToolServiceServer interface { type CloudToolServiceServer interface {
// Dozzle sends ToolResponse, cloud sends ToolRequest // Dozzle sends ToolResponse, cloud sends ToolRequest
ToolStream(grpc.BidiStreamingServer[ToolResponse, ToolRequest]) error ToolStream(grpc.BidiStreamingServer[ToolResponse, ToolRequest]) error
// Dozzle-initiated unary call: search log lines this instance has streamed
// to Cloud (gated by streamLogs opt-in). Cloud scopes the query server-side
// to the (user_id, api_key_id) derived from the authenticated connection;
// Dozzle does NOT pass any identity fields in the request.
SearchLogs(context.Context, *SearchLogsRequest) (*SearchLogsResponse, error)
mustEmbedUnimplementedCloudToolServiceServer() mustEmbedUnimplementedCloudToolServiceServer()
} }
@@ -70,6 +91,9 @@ type UnimplementedCloudToolServiceServer struct{}
func (UnimplementedCloudToolServiceServer) ToolStream(grpc.BidiStreamingServer[ToolResponse, ToolRequest]) error { func (UnimplementedCloudToolServiceServer) ToolStream(grpc.BidiStreamingServer[ToolResponse, ToolRequest]) error {
return status.Error(codes.Unimplemented, "method ToolStream not implemented") return status.Error(codes.Unimplemented, "method ToolStream not implemented")
} }
func (UnimplementedCloudToolServiceServer) SearchLogs(context.Context, *SearchLogsRequest) (*SearchLogsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SearchLogs not implemented")
}
func (UnimplementedCloudToolServiceServer) mustEmbedUnimplementedCloudToolServiceServer() {} func (UnimplementedCloudToolServiceServer) mustEmbedUnimplementedCloudToolServiceServer() {}
func (UnimplementedCloudToolServiceServer) testEmbeddedByValue() {} func (UnimplementedCloudToolServiceServer) testEmbeddedByValue() {}
@@ -98,13 +122,36 @@ func _CloudToolService_ToolStream_Handler(srv interface{}, stream grpc.ServerStr
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type CloudToolService_ToolStreamServer = grpc.BidiStreamingServer[ToolResponse, ToolRequest] type CloudToolService_ToolStreamServer = grpc.BidiStreamingServer[ToolResponse, ToolRequest]
func _CloudToolService_SearchLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SearchLogsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CloudToolServiceServer).SearchLogs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CloudToolService_SearchLogs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CloudToolServiceServer).SearchLogs(ctx, req.(*SearchLogsRequest))
}
return interceptor(ctx, in, info, handler)
}
// CloudToolService_ServiceDesc is the grpc.ServiceDesc for CloudToolService service. // CloudToolService_ServiceDesc is the grpc.ServiceDesc for CloudToolService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
var CloudToolService_ServiceDesc = grpc.ServiceDesc{ var CloudToolService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "cloud.CloudToolService", ServiceName: "cloud.CloudToolService",
HandlerType: (*CloudToolServiceServer)(nil), HandlerType: (*CloudToolServiceServer)(nil),
Methods: []grpc.MethodDesc{}, Methods: []grpc.MethodDesc{
{
MethodName: "SearchLogs",
Handler: _CloudToolService_SearchLogs_Handler,
},
},
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
StreamName: "ToolStream", StreamName: "ToolStream",
+51
View File
@@ -7,6 +7,12 @@ option go_package = "github.com/amir20/dozzle/proto/cloud";
service CloudToolService { service CloudToolService {
// Dozzle sends ToolResponse, cloud sends ToolRequest // Dozzle sends ToolResponse, cloud sends ToolRequest
rpc ToolStream(stream ToolResponse) returns (stream ToolRequest); rpc ToolStream(stream ToolResponse) returns (stream ToolRequest);
// Dozzle-initiated unary call: search log lines this instance has streamed
// to Cloud (gated by streamLogs opt-in). Cloud scopes the query server-side
// to the (user_id, api_key_id) derived from the authenticated connection;
// Dozzle does NOT pass any identity fields in the request.
rpc SearchLogs(SearchLogsRequest) returns (SearchLogsResponse);
} }
message ToolRequest { message ToolRequest {
@@ -45,6 +51,12 @@ message LogBatchEntry {
string message = 5; string message = 5;
string stream = 6; // "stdout" or "stderr" string stream = 6; // "stdout" or "stderr"
string level = 7; // "info", "warn", "error", etc. (best-effort) string level = 7; // "info", "warn", "error", etc. (best-effort)
// Deterministic FNV-32a hash of the raw log line, the same id Dozzle
// stamps on LogEvent.Id. Cloud indexes this as a non-stream field so
// search results can produce a stable permanent link
// (/container/:id/time/:datetime?logId=...) that lands the user on
// exactly the matching line in the local log viewer.
uint32 log_id = 8;
} }
message ListToolsRequest {} message ListToolsRequest {}
@@ -230,3 +242,42 @@ message NotificationResult {
bool success = 1; bool success = 1;
string message = 2; string message = 2;
} }
// Cloud log search.
message SearchLogsRequest {
// Substring/word-filter query. Empty -> empty result. Whitespace-only
// is rejected client-side; server treats as empty.
string query = 1;
// Result cap. Default 20, server-capped at 50.
int32 limit = 2;
// Pagination cursor: return only hits with timestamp_ns < this value.
// 0 = newest. Reserved for future use.
int64 before_ts_ns = 3;
// Optional filter — narrow to a specific Docker host inside the instance.
// Empty = all hosts under this instance.
string host_id = 4;
// Optional filter — narrow to a specific container.
string container_id = 5;
}
message SearchLogsResponse {
repeated SearchLogHit hits = 1;
bool has_more = 2;
// For pagination: pass back as before_ts_ns to fetch the next page.
int64 next_before_ts_ns = 3;
}
message SearchLogHit {
int64 timestamp_ns = 1;
string host_id = 2;
string container_id = 3;
string container_name = 4;
// Full log line as indexed.
string message = 5;
string stream = 6;
string level = 7;
// FNV-32a hash of the raw log line — same id Dozzle assigns LogEvent.Id
// and exposes via "Copy permalink". Lets the search-result row deep-link
// straight to the matching line.
uint32 log_id = 8;
}