feat(cloud-proto): add SearchLogs unary RPC (#4672)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -54,6 +54,7 @@ declare global {
|
||||
const drawerContext: typeof import('./composable/drawer').drawerContext
|
||||
const eagerComputed: typeof import('@vueuse/core').eagerComputed
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const escapeHtml: typeof import('./utils/index').escapeHtml
|
||||
const extendRef: typeof import('@vueuse/core').extendRef
|
||||
const flattenJSON: typeof import('./utils/index').flattenJSON
|
||||
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
|
||||
@@ -68,6 +69,7 @@ declare global {
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||
const hourStyle: typeof import('./stores/settings').hourStyle
|
||||
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
|
||||
const inject: typeof import('vue').inject
|
||||
@@ -158,6 +160,7 @@ declare global {
|
||||
const stripVersion: typeof import('./utils/index').stripVersion
|
||||
const syncRef: typeof import('@vueuse/core').syncRef
|
||||
const syncRefs: typeof import('@vueuse/core').syncRefs
|
||||
const syntaxHighlightJson: typeof import('./utils/index').syntaxHighlightJson
|
||||
const templateRef: typeof import('@vueuse/core').templateRef
|
||||
const throttledRef: typeof import('@vueuse/core').throttledRef
|
||||
const throttledWatch: typeof import('@vueuse/core').throttledWatch
|
||||
@@ -168,6 +171,7 @@ declare global {
|
||||
const toRelativeTime: typeof import('./utils/index').toRelativeTime
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const tryFormatJson: typeof import('./utils/index').tryFormatJson
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
|
||||
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
|
||||
@@ -206,6 +210,7 @@ declare global {
|
||||
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||
const useCloned: typeof import('@vueuse/core').useCloned
|
||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
@@ -254,6 +259,7 @@ declare global {
|
||||
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
|
||||
const useFps: typeof import('@vueuse/core').useFps
|
||||
const useFullscreen: typeof import('@vueuse/core').useFullscreen
|
||||
const useFuzzySearch: typeof import('./composable/fuzzySearch').useFuzzySearch
|
||||
const useGamepad: typeof import('@vueuse/core').useGamepad
|
||||
const useGeolocation: typeof import('@vueuse/core').useGeolocation
|
||||
const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream
|
||||
@@ -407,6 +413,9 @@ declare global {
|
||||
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
|
||||
import('./composable/alertForm')
|
||||
// @ts-ignore
|
||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||
import('./composable/cloudLogSearch')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
@@ -637,6 +646,7 @@ declare module 'vue' {
|
||||
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
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 useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
@@ -685,6 +695,7 @@ declare module 'vue' {
|
||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
||||
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 useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
|
||||
|
||||
@@ -37,6 +37,7 @@ declare module 'vue' {
|
||||
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
|
||||
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
|
||||
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
|
||||
CloudSearchInline: typeof import('./components/CloudSearchInline.vue')['default']
|
||||
CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.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']
|
||||
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['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']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.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:alert': typeof import('~icons/mdi/alert')['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: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:beer': typeof import('~icons/mdi/beer')['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:close': typeof import('~icons/mdi/close')['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: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:contentCopy': typeof import('~icons/mdi/content-copy')['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:github': typeof import('~icons/mdi/github')['default']
|
||||
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
|
||||
|
||||
@@ -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>
|
||||
@@ -140,7 +140,7 @@ const {
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
fetchCloudConfig,
|
||||
initialLoad,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
} = useCloudConfig();
|
||||
@@ -199,7 +199,7 @@ async function doUnlink() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCloudConfig();
|
||||
await initialLoad;
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("<FuzzySearchModal />", () => {
|
||||
await wrapper.find("input").setValue("foo");
|
||||
expect(wrapper.findAll("li").length).toBe(1);
|
||||
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>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,70 +1,159 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-open w-full shadow-md">
|
||||
<div class="input input-xl input-primary flex w-full items-center">
|
||||
<mdi:magnify class="flex size-8" />
|
||||
<!-- Single bordered card containing the input, results, and footer in one
|
||||
frame to match the design mock. No daisyUI input/dropdown chrome. -->
|
||||
<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
|
||||
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"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@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)"
|
||||
v-model="query"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
:placeholder="placeholderCopy"
|
||||
/>
|
||||
<form method="dialog" class="flex">
|
||||
<button v-if="isMobile">
|
||||
<mdi:close />
|
||||
<button v-if="isMobile" class="text-base-content/50 hover:text-base-content">
|
||||
<mdi:close class="size-5" />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
<button v-else>
|
||||
<kbd class="kbd kbd-xs">esc</kbd>
|
||||
</button>
|
||||
</form>
|
||||
</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">
|
||||
|
||||
<!-- Body: results + log search CTA. Only renders when there is something
|
||||
to show — keeps the empty modal compact. -->
|
||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Containers section -->
|
||||
<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="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
|
||||
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)"
|
||||
:class="{ 'menu-focus': index === selectedIndex }"
|
||||
>
|
||||
<div :class="{ 'text-primary': result.item.state === 'running' }">
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 />
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple />
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack />
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="font-light">{{ result.item.host }}</span> /
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span data-name v-html="matchedName(result)"></span>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
|
||||
<RelativeTime :date="result.item.created" class="text-xs font-light" />
|
||||
<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="hover:text-secondary"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" />
|
||||
<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 class="text-base-content/50 mt-0.5 flex items-center gap-1 text-xs">
|
||||
<template v-if="cloudSearch.available.value">
|
||||
<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>
|
||||
</template>
|
||||
@@ -73,15 +162,23 @@
|
||||
import { ContainerState } from "@/types/Container";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
import { type FuseResult } from "fuse.js";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||
|
||||
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 listItems = ref<HTMLInputElement[]>();
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const router = useRouter();
|
||||
const containerStore = useContainerStore();
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
@@ -89,12 +186,27 @@ const { visibleContainers } = storeToRefs(containerStore);
|
||||
const swarmStore = useSwarmStore();
|
||||
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 () => {
|
||||
const dialog = input.value?.closest("dialog");
|
||||
if (dialog) {
|
||||
const animations = dialog.getAnimations();
|
||||
await Promise.all(animations.map((animation) => animation.finished));
|
||||
input.value?.focus();
|
||||
if (initialQuery) input.value?.select();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,6 +305,25 @@ function selected(item: Item) {
|
||||
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 }) {
|
||||
pinnedStore.pinContainer(container);
|
||||
close();
|
||||
|
||||
@@ -41,10 +41,11 @@ defineExpose({
|
||||
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 () => {
|
||||
await nextTick();
|
||||
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
document.getElementById(targetId)?.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<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, "&").replace(/</g, "<").replace(/>/g, ">")), 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);
|
||||
</script>
|
||||
<style scoped>
|
||||
@@ -179,22 +157,4 @@ useSortable(list, fields);
|
||||
Menlo,
|
||||
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>
|
||||
|
||||
@@ -14,12 +14,7 @@
|
||||
>
|
||||
{{ container.name }}
|
||||
</RandomColorTag>
|
||||
<LogDate
|
||||
v-if="showTimestamp"
|
||||
:date="logEntry.date"
|
||||
class="shrink-0 select-none"
|
||||
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
|
||||
/>
|
||||
<LogDate v-if="showTimestamp" :date="logEntry.date" class="shrink-0 select-none" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -37,6 +32,4 @@ const { hosts } = useHosts();
|
||||
|
||||
const container = currentContainer(toRef(() => logEntry.containerID));
|
||||
const host = computed(() => hosts.value[container.value.host]);
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:id="item.id.toString()"
|
||||
:data-time="item.date.getTime()"
|
||||
class="group/entry"
|
||||
:class="{ 'log-permalink-target': permalinkLogId === item.id.toString() }"
|
||||
>
|
||||
<component :is="item.getComponent()" :log-entry="item" />
|
||||
</li>
|
||||
@@ -24,6 +25,9 @@ const { messages } = defineProps<{
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const route = useRoute();
|
||||
const permalinkLogId = computed(() => (typeof route.query.logId === "string" ? route.query.logId : ""));
|
||||
|
||||
const list = ref<HTMLElement[]>([]);
|
||||
|
||||
let previousDate = new Date();
|
||||
@@ -71,6 +75,11 @@ ul {
|
||||
&:last-child {
|
||||
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 {
|
||||
@@ -113,4 +122,14 @@ ul {
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 px-4 py-4 md:px-8">
|
||||
<section>
|
||||
<Links>
|
||||
<section class="flex items-center gap-4">
|
||||
<CloudSearchInline class="hidden max-w-sm flex-1 md:flex" />
|
||||
<Links class="ml-auto">
|
||||
<template #more-items>
|
||||
<Tag class="font-mono">{{ config.version }}</Tag>
|
||||
</template>
|
||||
|
||||
@@ -9,17 +9,6 @@
|
||||
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
|
||||
</h1>
|
||||
|
||||
<button
|
||||
class="input input-sm hover:border-primary mt-2 inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
|
||||
@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" />
|
||||
</aside>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -7,7 +7,7 @@
|
||||
<ph:command v-if="isMac" class="size-4" />
|
||||
<ph:control-bold v-else class="size-4" />
|
||||
</template>
|
||||
<kbd class="uppercase">{{ char }}</kbd>
|
||||
<kbd class="kbd kbd-xs ml-0.5 uppercase">{{ char }}</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
if (!cloudConfig.value?.linked) return;
|
||||
isLoadingCloudStatus.value = true;
|
||||
@@ -49,6 +53,7 @@ export function useCloudConfig() {
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
initialLoad,
|
||||
fetchCloudConfig,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
|
||||
<Splitpanes @resized="onResized($event)">
|
||||
<Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
|
||||
<SidePanel @search="showFuzzySearch" />
|
||||
<SidePanel />
|
||||
</Pane>
|
||||
<Pane min-size="10" :size="100 - menuWidth">
|
||||
<Splitpanes>
|
||||
@@ -34,9 +34,9 @@
|
||||
<mdi:chevron-left class="swap-off" />
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="open = false">
|
||||
<dialog ref="modal" class="modal 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">
|
||||
<FuzzySearchModal @close="open = false" v-if="open" />
|
||||
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
@@ -63,8 +63,10 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
const drawer = useTemplateRef<InstanceType<typeof SideDrawer>>("drawer") as Ref<InstanceType<typeof SideDrawer>>;
|
||||
const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer);
|
||||
|
||||
import { useFuzzySearch } from "@/composable/fuzzySearch";
|
||||
|
||||
const modal = ref<HTMLDialogElement>();
|
||||
const open = ref(false);
|
||||
const { open, openSearch: showFuzzySearch, closeSearch } = useFuzzySearch();
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const forceMenuHidden = ref(searchParams.has("hideMenu"));
|
||||
|
||||
@@ -83,10 +85,6 @@ onKeyStroke("k", (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
function showFuzzySearch() {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function onResized({ panes }: { panes: { size: number }[] }) {
|
||||
if (panes.length == 2) {
|
||||
menuWidth.value = Math.min(panes[0].size, 50);
|
||||
|
||||
@@ -214,3 +214,6 @@ body {
|
||||
.status-pill-warning {
|
||||
@apply text-warning border-warning/30 bg-warning/10;
|
||||
}
|
||||
.status-pill-error {
|
||||
@apply text-error border-error/30 bg-error/10;
|
||||
}
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>
|
||||
@@ -44,6 +44,13 @@ declare module 'vue-router/auto-routes' {
|
||||
{ all: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/cloud/search': RouteRecordInfo<
|
||||
'/cloud/search',
|
||||
'/cloud/search',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/container/[id]': RouteRecordInfo<
|
||||
'/container/[id]',
|
||||
'/container/:id',
|
||||
@@ -167,6 +174,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/cloud/search.vue': {
|
||||
routes:
|
||||
| '/cloud/search'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/container/[id].vue': {
|
||||
routes:
|
||||
| '/container/[id]'
|
||||
|
||||
@@ -19,7 +19,7 @@ test("click on settings button", async ({ page }) => {
|
||||
|
||||
test("shortcut for fuzzy search", async ({ page }) => {
|
||||
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 }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
@@ -51,6 +51,14 @@ type Client struct {
|
||||
|
||||
connMu sync.Mutex
|
||||
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.
|
||||
|
||||
@@ -207,6 +207,7 @@ func (ls *logStreamer) runReader(ctx context.Context, cs *container_support.Cont
|
||||
Message: msg,
|
||||
Stream: ev.Stream,
|
||||
Level: level,
|
||||
LogId: ev.Id,
|
||||
})
|
||||
batchBytes += len(msg)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -128,8 +128,20 @@ func (g *EventGenerator) skipOrphanedLines() *LogEvent {
|
||||
|
||||
if !isOrphan {
|
||||
if len(orphanBuffer) > 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -360,8 +360,11 @@ func TestEventGenerator_OrphanNotSkipped_AllLevellessLines(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventGenerator_OrphanNotSkipped_TimestampGapBreaksOrphanDetection(t *testing.T) {
|
||||
// Lines far apart in time — first is buffered as orphan candidate, but the
|
||||
// gap breaks the chain so it's treated as non-orphan.
|
||||
// Lines far apart in time — first is buffered as orphan candidate but the
|
||||
// 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)
|
||||
messages := []string{
|
||||
"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})
|
||||
|
||||
// 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
|
||||
require.NotNil(t, event1)
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
// immediately instead of riding out the old stream.
|
||||
if h.config.OnCloudUpdate != nil {
|
||||
h.config.OnCloudUpdate()
|
||||
if h.config.Cloud.OnUpdate != nil {
|
||||
h.config.Cloud.OnUpdate()
|
||||
}
|
||||
if h.config.OnCloudSetup != nil {
|
||||
h.config.OnCloudSetup()
|
||||
if h.config.Cloud.OnSetup != nil {
|
||||
h.config.Cloud.OnSetup()
|
||||
}
|
||||
|
||||
base := h.config.Base
|
||||
@@ -174,8 +174,8 @@ func (h *handler) cloudStatus(w http.ResponseWriter, r *http.Request) {
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal(body, &statusResp) == nil && statusResp.Plan.Name == "pro" {
|
||||
if h.config.OnCloudSetup != nil {
|
||||
h.config.OnCloudSetup()
|
||||
if h.config.Cloud.OnSetup != nil {
|
||||
h.config.Cloud.OnSetup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,8 +234,8 @@ func (h *handler) updateCloudConfig(w http.ResponseWriter, r *http.Request) {
|
||||
h.hostService.SetCloudStreamLogs(*req.StreamLogs)
|
||||
// Drop the active cloud connection so the new flag is picked up on
|
||||
// the next dial — a streamer may need to start or stop.
|
||||
if h.config.OnCloudUpdate != nil {
|
||||
h.config.OnCloudUpdate()
|
||||
if h.config.Cloud.OnUpdate != nil {
|
||||
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
|
||||
// instance immediately, instead of riding out the existing stream with
|
||||
// a now-deleted key.
|
||||
if h.config.OnCloudUpdate != nil {
|
||||
h.config.OnCloudUpdate()
|
||||
if h.config.Cloud.OnUpdate != nil {
|
||||
h.config.Cloud.OnUpdate()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/amir20/dozzle/internal/auth"
|
||||
"github.com/amir20/dozzle/internal/cloud"
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
"github.com/amir20/dozzle/internal/notification"
|
||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||
@@ -50,8 +51,23 @@ type Config struct {
|
||||
DisableAvatars bool
|
||||
ReleaseCheckMode ReleaseCheckMode
|
||||
Labels container.ContainerLabels
|
||||
OnCloudSetup func()
|
||||
OnCloudUpdate func()
|
||||
Cloud CloudHooks
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -192,6 +208,7 @@ func createRouter(h *handler) *chi.Mux {
|
||||
|
||||
// Cloud API
|
||||
r.Get("/cloud/status", h.cloudStatus)
|
||||
r.Get("/cloud/search/logs", h.cloudSearchLogs)
|
||||
r.Get("/cloud/config", h.cloudConfig)
|
||||
r.Patch("/cloud/config", h.updateCloudConfig)
|
||||
r.Delete("/cloud/config", h.deleteCloudConfig)
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: Opret din første advarsel
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: Erste Benachrichtigung erstellen
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -113,6 +113,34 @@ button:
|
||||
placeholder:
|
||||
search-containers: Search containers (⌘ + k, ⌃k)
|
||||
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:
|
||||
help-support: >
|
||||
Please support Dozzle by donating or sponsoring us on GitHub. Your contributions help us improve Dozzle for
|
||||
|
||||
@@ -106,6 +106,34 @@ button:
|
||||
placeholder:
|
||||
search-containers: Buscar contenedores (⌘ + K, CTRL + K)
|
||||
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:
|
||||
help-support: >
|
||||
Por favor, apoya a Dozzle donando o patrocinándonos en GitHub. Tus contribuciones nos ayudan a mejorar Dozzle para todos. ¡Gracias! 🙏🏼
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: Créer votre première alerte
|
||||
default-alert-name: Container exited with error
|
||||
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é"
|
||||
|
||||
@@ -351,3 +351,31 @@ cloud:
|
||||
create-alert: Buat peringatan pertama Anda
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: Crea il tuo primo avviso
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -342,3 +342,31 @@ cloud:
|
||||
create-alert: 첫 번째 알림 만들기
|
||||
default-alert-name: Container exited with error
|
||||
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: "삭제됨"
|
||||
|
||||
@@ -340,3 +340,31 @@ cloud:
|
||||
create-alert: Maak je eerste melding
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -346,3 +346,31 @@ cloud:
|
||||
create-alert: Utwórz pierwszy alert
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -348,3 +348,31 @@ cloud:
|
||||
create-alert: Create Yer First Alert
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -338,3 +338,31 @@ cloud:
|
||||
create-alert: Crie seu primeiro alerta
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: Создайте первое оповещение
|
||||
default-alert-name: Container exited with error
|
||||
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: "удалён"
|
||||
|
||||
@@ -344,3 +344,31 @@ cloud:
|
||||
create-alert: Ustvarite prvo opozorilo
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: İlk uyarınızı oluşturun
|
||||
default-alert-name: Container exited with error
|
||||
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"
|
||||
|
||||
@@ -342,3 +342,31 @@ cloud:
|
||||
create-alert: 建立您的第一個警示
|
||||
default-alert-name: Container exited with error
|
||||
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: "已刪除"
|
||||
|
||||
@@ -339,3 +339,31 @@ cloud:
|
||||
create-alert: 创建您的第一个警报
|
||||
default-alert-name: Container exited with error
|
||||
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: "已删除"
|
||||
|
||||
@@ -186,7 +186,11 @@ func main() {
|
||||
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() {
|
||||
log.Info().Msgf("Accepting connections on %s", args.Addr)
|
||||
@@ -214,7 +218,7 @@ func fileExists(filename string) bool {
|
||||
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")
|
||||
|
||||
var releaseCheckMode web.ReleaseCheckMode = web.Automatic
|
||||
@@ -293,8 +297,7 @@ func createServer(args cli.Args, hostService web.HostService, onCloudSetup func(
|
||||
DisableAvatars: args.DisableAvatars,
|
||||
ReleaseCheckMode: releaseCheckMode,
|
||||
Labels: args.Filter,
|
||||
OnCloudSetup: onCloudSetup,
|
||||
OnCloudUpdate: onCloudUpdate,
|
||||
Cloud: cloudHooks,
|
||||
}
|
||||
|
||||
assets, err := fs.Sub(content, "dist")
|
||||
|
||||
@@ -353,6 +353,12 @@ type LogBatchEntry struct {
|
||||
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"
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -436,6 +442,13 @@ func (x *LogBatchEntry) GetLevel() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogBatchEntry) GetLogId() uint32 {
|
||||
if x != nil {
|
||||
return x.LogId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListToolsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -1910,6 +1923,256 @@ func (x *NotificationResult) GetMessage() string {
|
||||
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
|
||||
|
||||
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" +
|
||||
"\x04type\":\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" +
|
||||
"\ahost_id\x18\x01 \x01(\tR\x06hostId\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" +
|
||||
"\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\n" +
|
||||
"\x05level\x18\a \x01(\tR\x05level\x12\x15\n" +
|
||||
"\x06log_id\x18\b \x01(\rR\x05logId\"\x12\n" +
|
||||
"\x10ListToolsRequest\"Z\n" +
|
||||
"\x11ListToolsResponse\x12+\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" +
|
||||
"\x12NotificationResult\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" +
|
||||
"\x16TOOL_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" +
|
||||
"\x13TOOL_SCOPE_INSTANCE\x10\x01\x12\x13\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" +
|
||||
"\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 (
|
||||
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_msgTypes = make([]protoimpl.MessageInfo, 23)
|
||||
var file_cloud_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
|
||||
var file_cloud_proto_goTypes = []any{
|
||||
(ToolScope)(0), // 0: cloud.ToolScope
|
||||
(*ToolRequest)(nil), // 1: cloud.ToolRequest
|
||||
@@ -2116,7 +2402,10 @@ var file_cloud_proto_goTypes = []any{
|
||||
(*ActionResult)(nil), // 20: cloud.ActionResult
|
||||
(*DeployResult)(nil), // 21: cloud.DeployResult
|
||||
(*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{
|
||||
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
|
||||
15, // 19: cloud.ContainerStatsResult.stats:type_name -> cloud.ContainerStatEntry
|
||||
17, // 20: cloud.FetchLogsResult.entries:type_name -> cloud.LogEntry
|
||||
23, // 21: cloud.InspectContainerResult.labels:type_name -> cloud.InspectContainerResult.LabelsEntry
|
||||
2, // 22: cloud.CloudToolService.ToolStream:input_type -> cloud.ToolResponse
|
||||
1, // 23: cloud.CloudToolService.ToolStream:output_type -> cloud.ToolRequest
|
||||
23, // [23:24] is the sub-list for method output_type
|
||||
22, // [22:23] is the sub-list for method input_type
|
||||
22, // [22:22] is the sub-list for extension type_name
|
||||
22, // [22:22] is the sub-list for extension extendee
|
||||
0, // [0:22] is the sub-list for field type_name
|
||||
26, // 21: cloud.InspectContainerResult.labels:type_name -> cloud.InspectContainerResult.LabelsEntry
|
||||
25, // 22: cloud.SearchLogsResponse.hits:type_name -> cloud.SearchLogHit
|
||||
2, // 23: cloud.CloudToolService.ToolStream:input_type -> cloud.ToolResponse
|
||||
23, // 24: cloud.CloudToolService.SearchLogs:input_type -> cloud.SearchLogsRequest
|
||||
1, // 25: cloud.CloudToolService.ToolStream:output_type -> cloud.ToolRequest
|
||||
24, // 26: cloud.CloudToolService.SearchLogs:output_type -> cloud.SearchLogsResponse
|
||||
25, // [25:27] is the sub-list for method output_type
|
||||
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() }
|
||||
@@ -2181,7 +2473,7 @@ func file_cloud_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cloud_proto_rawDesc), len(file_cloud_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 23,
|
||||
NumMessages: 26,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
CloudToolService_ToolStream_FullMethodName = "/cloud.CloudToolService/ToolStream"
|
||||
CloudToolService_SearchLogs_FullMethodName = "/cloud.CloudToolService/SearchLogs"
|
||||
)
|
||||
|
||||
// CloudToolServiceClient is the client API for CloudToolService service.
|
||||
@@ -28,6 +29,11 @@ const (
|
||||
type CloudToolServiceClient interface {
|
||||
// Dozzle sends ToolResponse, cloud sends ToolRequest
|
||||
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 {
|
||||
@@ -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.
|
||||
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.
|
||||
// All implementations must embed UnimplementedCloudToolServiceServer
|
||||
// for forward compatibility.
|
||||
type CloudToolServiceServer interface {
|
||||
// Dozzle sends ToolResponse, cloud sends ToolRequest
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -70,6 +91,9 @@ type UnimplementedCloudToolServiceServer struct{}
|
||||
func (UnimplementedCloudToolServiceServer) ToolStream(grpc.BidiStreamingServer[ToolResponse, ToolRequest]) error {
|
||||
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) 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.
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var CloudToolService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "cloud.CloudToolService",
|
||||
HandlerType: (*CloudToolServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{},
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "SearchLogs",
|
||||
Handler: _CloudToolService_SearchLogs_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "ToolStream",
|
||||
|
||||
@@ -7,6 +7,12 @@ option go_package = "github.com/amir20/dozzle/proto/cloud";
|
||||
service CloudToolService {
|
||||
// Dozzle sends ToolResponse, cloud sends 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 {
|
||||
@@ -45,6 +51,12 @@ message LogBatchEntry {
|
||||
string message = 5;
|
||||
string stream = 6; // "stdout" or "stderr"
|
||||
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 {}
|
||||
@@ -230,3 +242,42 @@ message NotificationResult {
|
||||
bool success = 1;
|
||||
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;
|
||||
}
|
||||
|
||||