diff --git a/assets/auto-imports.d.ts b/assets/auto-imports.d.ts index 3b2d67ee..7247bfa5 100644 --- a/assets/auto-imports.d.ts +++ b/assets/auto-imports.d.ts @@ -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 readonly useCloned: UnwrapRef readonly useCloudConfig: UnwrapRef + readonly useCloudLogSearch: UnwrapRef readonly useColorMode: UnwrapRef readonly useConfirmDialog: UnwrapRef readonly useContainerActions: UnwrapRef @@ -685,6 +695,7 @@ declare module 'vue' { readonly useFocusWithin: UnwrapRef readonly useFps: UnwrapRef readonly useFullscreen: UnwrapRef + readonly useFuzzySearch: UnwrapRef readonly useGamepad: UnwrapRef readonly useGeolocation: UnwrapRef readonly useGroupedStream: UnwrapRef diff --git a/assets/components.d.ts b/assets/components.d.ts index 651360e2..535358dd 100644 --- a/assets/components.d.ts +++ b/assets/components.d.ts @@ -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'] diff --git a/assets/components/CloudSearchInline.vue b/assets/components/CloudSearchInline.vue new file mode 100644 index 00000000..2de11860 --- /dev/null +++ b/assets/components/CloudSearchInline.vue @@ -0,0 +1,35 @@ + + + diff --git a/assets/components/CloudSettingsCard.vue b/assets/components/CloudSettingsCard.vue index 1396ddf4..e2de36e4 100644 --- a/assets/components/CloudSettingsCard.vue +++ b/assets/components/CloudSettingsCard.vue @@ -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(); } diff --git a/assets/components/FuzzySearchModal.spec.ts b/assets/components/FuzzySearchModal.spec.ts index d56846fd..ed9e4945 100644 --- a/assets/components/FuzzySearchModal.spec.ts +++ b/assets/components/FuzzySearchModal.spec.ts @@ -109,7 +109,7 @@ describe("", () => { await wrapper.find("input").setValue("foo"); expect(wrapper.findAll("li").length).toBe(1); expect(wrapper.find("ul [data-name]").html()).toMatchInlineSnapshot( - `"foo bar"`, + `"foo bar"`, ); }); diff --git a/assets/components/FuzzySearchModal.vue b/assets/components/FuzzySearchModal.vue index d3b0e617..c2779c96 100644 --- a/assets/components/FuzzySearchModal.vue +++ b/assets/components/FuzzySearchModal.vue @@ -1,70 +1,159 @@ @@ -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(); const listItems = ref(); 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(); diff --git a/assets/components/LogViewer/EventSource.vue b/assets/components/LogViewer/EventSource.vue index 421e6ee3..9161c1c2 100644 --- a/assets/components/LogViewer/EventSource.vue +++ b/assets/components/LogViewer/EventSource.vue @@ -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" }); }); } diff --git a/assets/components/LogViewer/LogDetails.vue b/assets/components/LogViewer/LogDetails.vue index 11a5610b..72026981 100644 --- a/assets/components/LogViewer/LogDetails.vue +++ b/assets/components/LogViewer/LogDetails.vue @@ -37,7 +37,7 @@
-

+        
       
@@ -142,28 +142,6 @@ const toggleAllFields = computed({ }, }); -function syntaxHighlight(json: string) { - json = JSON.stringify(JSON.parse(json.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 `${match}`; - }, - ); -} - useSortable(list, fields); diff --git a/assets/components/LogViewer/LogItem.vue b/assets/components/LogViewer/LogItem.vue index 15051fb0..5879a542 100644 --- a/assets/components/LogViewer/LogItem.vue +++ b/assets/components/LogViewer/LogItem.vue @@ -14,12 +14,7 @@ > {{ container.name }} - + @@ -37,6 +32,4 @@ const { hosts } = useHosts(); const container = currentContainer(toRef(() => logEntry.containerID)); const host = computed(() => hosts.value[container.value.host]); - -const route = useRoute(); diff --git a/assets/components/LogViewer/LogList.vue b/assets/components/LogViewer/LogList.vue index f10c9437..a9f982ae 100644 --- a/assets/components/LogViewer/LogList.vue +++ b/assets/components/LogViewer/LogList.vue @@ -7,6 +7,7 @@ :id="item.id.toString()" :data-time="item.date.getTime()" class="group/entry" + :class="{ 'log-permalink-target': permalinkLogId === item.id.toString() }" > @@ -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([]); 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); + } +} diff --git a/assets/components/PageWithLinks.vue b/assets/components/PageWithLinks.vue index 20236836..65f9050f 100644 --- a/assets/components/PageWithLinks.vue +++ b/assets/components/PageWithLinks.vue @@ -1,7 +1,8 @@ diff --git a/assets/components/common/JsonFormatted.vue b/assets/components/common/JsonFormatted.vue new file mode 100644 index 00000000..1298c018 --- /dev/null +++ b/assets/components/common/JsonFormatted.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/assets/components/common/JsonText.vue b/assets/components/common/JsonText.vue new file mode 100644 index 00000000..cee2a6b9 --- /dev/null +++ b/assets/components/common/JsonText.vue @@ -0,0 +1,31 @@ + + + diff --git a/assets/components/common/JsonValue.vue b/assets/components/common/JsonValue.vue new file mode 100644 index 00000000..64dc3c99 --- /dev/null +++ b/assets/components/common/JsonValue.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/assets/components/common/KeyShortcut.vue b/assets/components/common/KeyShortcut.vue index d412b4df..b29491e8 100644 --- a/assets/components/common/KeyShortcut.vue +++ b/assets/components/common/KeyShortcut.vue @@ -7,7 +7,7 @@ - {{ char }} + {{ char }} diff --git a/assets/composable/cloudConfig.ts b/assets/composable/cloudConfig.ts index 7e774548..747c4560 100644 --- a/assets/composable/cloudConfig.ts +++ b/assets/composable/cloudConfig.ts @@ -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, diff --git a/assets/composable/cloudLogSearch.ts b/assets/composable/cloudLogSearch.ts new file mode 100644 index 00000000..7dab9e91 --- /dev/null +++ b/assets/composable/cloudLogSearch.ts @@ -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) { + const { cloudConfig } = useCloudConfig(); + + const results = ref([]); + const loading = ref(false); + const loadingMore = ref(false); + const error = ref(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(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 { + 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 }; +} diff --git a/assets/composable/fuzzySearch.ts b/assets/composable/fuzzySearch.ts new file mode 100644 index 00000000..4fc7ca99 --- /dev/null +++ b/assets/composable/fuzzySearch.ts @@ -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; + }, + }; +} diff --git a/assets/layouts/default.vue b/assets/layouts/default.vue index 83db5328..1b066e52 100644 --- a/assets/layouts/default.vue +++ b/assets/layouts/default.vue @@ -3,7 +3,7 @@ - + @@ -34,9 +34,9 @@ - + @@ -63,8 +63,10 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore); const drawer = useTemplateRef>("drawer") as Ref>; const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer); +import { useFuzzySearch } from "@/composable/fuzzySearch"; + const modal = ref(); -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); diff --git a/assets/main.css b/assets/main.css index 2db1ce55..72da8ab8 100644 --- a/assets/main.css +++ b/assets/main.css @@ -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; +} diff --git a/assets/pages/cloud/search.vue b/assets/pages/cloud/search.vue new file mode 100644 index 00000000..8f5bcb2e --- /dev/null +++ b/assets/pages/cloud/search.vue @@ -0,0 +1,232 @@ + + + diff --git a/assets/typed-router.d.ts b/assets/typed-router.d.ts index a93c008d..27c52cb9 100644 --- a/assets/typed-router.d.ts +++ b/assets/typed-router.d.ts @@ -44,6 +44,13 @@ declare module 'vue-router/auto-routes' { { all: ParamValue }, | never >, + '/cloud/search': RouteRecordInfo< + '/cloud/search', + '/cloud/search', + Record, + Record, + | 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]' diff --git a/e2e/default.spec.ts b/e2e/default.spec.ts index 7b355734..40f33e27 100644 --- a/e2e/default.spec.ts +++ b/e2e/default.spec.ts @@ -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 }) => { diff --git a/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png b/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png index c27523b1..8163f462 100644 Binary files a/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png and b/e2e/visual.spec.ts-snapshots/dark-homepage-1-Mobile-Chrome-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/dark-homepage-1-chromium-linux.png b/e2e/visual.spec.ts-snapshots/dark-homepage-1-chromium-linux.png index caff1ef2..e5bd3880 100644 Binary files a/e2e/visual.spec.ts-snapshots/dark-homepage-1-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/dark-homepage-1-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/default-homepage-1-Mobile-Chrome-linux.png b/e2e/visual.spec.ts-snapshots/default-homepage-1-Mobile-Chrome-linux.png index c9bba9f6..11a23dc4 100644 Binary files a/e2e/visual.spec.ts-snapshots/default-homepage-1-Mobile-Chrome-linux.png and b/e2e/visual.spec.ts-snapshots/default-homepage-1-Mobile-Chrome-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/default-homepage-1-chromium-linux.png b/e2e/visual.spec.ts-snapshots/default-homepage-1-chromium-linux.png index fced527c..41f5e8b9 100644 Binary files a/e2e/visual.spec.ts-snapshots/default-homepage-1-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/default-homepage-1-chromium-linux.png differ diff --git a/internal/cloud/client.go b/internal/cloud/client.go index 8f30f6af..605bdf0d 100644 --- a/internal/cloud/client.go +++ b/internal/cloud/client.go @@ -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. diff --git a/internal/cloud/log_streamer.go b/internal/cloud/log_streamer.go index 3fd02dfa..fdd21075 100644 --- a/internal/cloud/log_streamer.go +++ b/internal/cloud/log_streamer.go @@ -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) diff --git a/internal/cloud/search.go b/internal/cloud/search.go new file mode 100644 index 00000000..86f1ee60 --- /dev/null +++ b/internal/cloud/search.go @@ -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 +} diff --git a/internal/container/event_generator.go b/internal/container/event_generator.go index 38c54b09..3c147ecb 100644 --- a/internal/container/event_generator.go +++ b/internal/container/event_generator.go @@ -128,7 +128,19 @@ func (g *EventGenerator) skipOrphanedLines() *LogEvent { if !isOrphan { if len(orphanBuffer) > 0 { - log.Debug().Int("count", len(orphanBuffer)).Str("container", g.containerID).Msg("skipped orphaned continuation lines") + // If the chain broke because `current` is far in time from the + // last buffered line, the buffered lines weren't continuations + // of anything — they're real isolated entries. Emit them as + // singles so first-of-window lines aren't silently dropped + // (e.g. postgres "checkpoint starting: time" — only entry in + // a 5-min window followed by a 0.4s-later "complete" line). + timeGap := lastTimestamp != 0 && current.Timestamp > 0 && + math.Abs(float64(lastTimestamp-current.Timestamp)) >= maxGroupTimeDelta + if timeGap { + g.emitAsSingles(orphanBuffer) + } else { + log.Debug().Int("count", len(orphanBuffer)).Str("container", g.containerID).Msg("skipped orphaned continuation lines") + } } return current } diff --git a/internal/container/event_generator_test.go b/internal/container/event_generator_test.go index 1a247f70..a4c5f799 100644 --- a/internal/container/event_generator_test.go +++ b/internal/container/event_generator_test.go @@ -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) { diff --git a/internal/web/cloud.go b/internal/web/cloud.go index 264e1015..750c36ea 100644 --- a/internal/web/cloud.go +++ b/internal/web/cloud.go @@ -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) } diff --git a/internal/web/cloud_search.go b/internal/web/cloud_search.go new file mode 100644 index 00000000..35725edc --- /dev/null +++ b/internal/web/cloud_search.go @@ -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) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 0ae0ec32..6df21173 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -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) diff --git a/locales/da.yml b/locales/da.yml index 32cdb77f..23ee36f1 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -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" diff --git a/locales/de.yml b/locales/de.yml index 1b2dde4b..8a15e0fa 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -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" diff --git a/locales/en.yml b/locales/en.yml index 5d5ff737..bc603f11 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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 diff --git a/locales/es.yml b/locales/es.yml index 96d73e28..9444898a 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -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! 🙏🏼 diff --git a/locales/fr.yml b/locales/fr.yml index 5e9bc1a7..959e1a32 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -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é" diff --git a/locales/id.yml b/locales/id.yml index ac93ea97..39835f42 100644 --- a/locales/id.yml +++ b/locales/id.yml @@ -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" diff --git a/locales/it.yml b/locales/it.yml index be9506c4..a3dbeb6f 100644 --- a/locales/it.yml +++ b/locales/it.yml @@ -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" diff --git a/locales/ko.yml b/locales/ko.yml index 59206abd..c17d47be 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -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: "삭제됨" diff --git a/locales/nl.yml b/locales/nl.yml index c17a2641..6904aeeb 100644 --- a/locales/nl.yml +++ b/locales/nl.yml @@ -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" diff --git a/locales/pl.yml b/locales/pl.yml index 9a8e7bee..58d0ba96 100644 --- a/locales/pl.yml +++ b/locales/pl.yml @@ -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" diff --git a/locales/pr.yml b/locales/pr.yml index 94a195f4..d214e6cc 100644 --- a/locales/pr.yml +++ b/locales/pr.yml @@ -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" diff --git a/locales/pt.yml b/locales/pt.yml index 2d82f7c4..925a9845 100644 --- a/locales/pt.yml +++ b/locales/pt.yml @@ -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" diff --git a/locales/ru.yml b/locales/ru.yml index 1b7281f6..c1ed9d1c 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -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: "удалён" diff --git a/locales/sl.yml b/locales/sl.yml index 7d5cf4bc..c8f92c17 100644 --- a/locales/sl.yml +++ b/locales/sl.yml @@ -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" diff --git a/locales/tr.yml b/locales/tr.yml index 8919de3e..3ffd8dd6 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -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" diff --git a/locales/zh-tw.yml b/locales/zh-tw.yml index 77bb5c55..e678dbbf 100644 --- a/locales/zh-tw.yml +++ b/locales/zh-tw.yml @@ -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: "已刪除" diff --git a/locales/zh.yml b/locales/zh.yml index fa287c6f..1ce7cd57 100644 --- a/locales/zh.yml +++ b/locales/zh.yml @@ -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: "已删除" diff --git a/main.go b/main.go index 0bae82f1..10652126 100644 --- a/main.go +++ b/main.go @@ -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") diff --git a/proto/cloud/cloud.pb.go b/proto/cloud/cloud.pb.go index ee286f45..0a8ac828 100644 --- a/proto/cloud/cloud.pb.go +++ b/proto/cloud/cloud.pb.go @@ -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, }, diff --git a/proto/cloud/cloud_grpc.pb.go b/proto/cloud/cloud_grpc.pb.go index 31c464ea..ceb9dc99 100644 --- a/proto/cloud/cloud_grpc.pb.go +++ b/proto/cloud/cloud_grpc.pb.go @@ -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", diff --git a/protos/cloud.proto b/protos/cloud.proto index d961d64c..2197e779 100644 --- a/protos/cloud.proto +++ b/protos/cloud.proto @@ -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; +}