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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-05-05 16:11:32 -07:00
committed by GitHub
parent 883f5464e3
commit 8dac197f60
57 changed files with 2048 additions and 168 deletions
+11
View File
@@ -54,6 +54,7 @@ declare global {
const drawerContext: typeof import('./composable/drawer').drawerContext
const 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']>
+9
View File
@@ -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']
+35
View File
@@ -0,0 +1,35 @@
<template>
<button
type="button"
data-testid="search"
class="bg-base-200 border-base-content/15 hover:border-primary/50 hover:bg-base-200/80 flex h-9 w-full items-center gap-2 rounded-md border px-3 text-left transition-colors"
@click="openSearch"
>
<mdi:magnify class="size-4 shrink-0" :class="cloudReady ? 'text-primary' : 'text-base-content/60'" />
<!-- Show the active query when we're on the cloud search page so the
topbar reflects what the user is looking at. -->
<span v-if="activeQuery" class="text-base-content truncate font-mono text-sm">{{ activeQuery }}</span>
<span v-else class="text-base-content/60 truncate text-sm">
<template v-if="cloudReady">{{ $t("cloud-search.hero-title-cloud") }}</template>
<template v-else>{{ $t("cloud-search.hero-title-plain") }}</template>
</span>
<span class="ml-auto flex items-center gap-1">
<kbd class="kbd kbd-xs"></kbd>
<kbd class="kbd kbd-xs">K</kbd>
</span>
</button>
</template>
<script lang="ts" setup>
import { useFuzzySearch } from "@/composable/fuzzySearch";
import { useCloudConfig } from "@/composable/cloudConfig";
const { openSearch } = useFuzzySearch();
const { cloudConfig } = useCloudConfig();
const cloudReady = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
const route = useRoute();
const activeQuery = computed(() =>
route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "",
);
</script>
+2 -2
View File
@@ -140,7 +140,7 @@ const {
cloudStatus,
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();
}
+1 -1
View File
@@ -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>"`,
);
});
+164 -33
View File
@@ -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();
+3 -2
View File
@@ -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" });
});
}
+1 -41
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")), null, 2);
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
function (match: string) {
var cls = "json-number";
if (match.startsWith('"')) {
if (match.endsWith(":")) {
cls = "json-key";
} else {
cls = "json-string";
}
} else if (/true|false/.test(match)) {
cls = "json-boolean";
} else if (/null/.test(match)) {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
},
);
}
useSortable(list, fields);
</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>
+1 -8
View File
@@ -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>
+19
View File
@@ -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>
+3 -2
View File
@@ -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>
-11
View File
@@ -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>
+31
View File
@@ -0,0 +1,31 @@
<template>
<template v-if="!highlight">{{ text }}</template>
<template v-else>
<template v-for="(part, i) in parts" :key="i">
<mark v-if="part.match" class="bg-warning text-warning-content rounded px-0.5">{{ part.text }}</mark>
<template v-else>{{ part.text }}</template>
</template>
</template>
</template>
<script lang="ts" setup>
const { text, highlight } = defineProps<{
text: string;
highlight?: string;
}>();
const parts = computed(() => {
if (!highlight) return [{ text, match: false }];
const pattern = highlight.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
const re = new RegExp(pattern, "gi");
const result: { text: string; match: boolean }[] = [];
let last = 0;
for (const m of text.matchAll(re)) {
if (m.index! > last) result.push({ text: text.slice(last, m.index), match: false });
result.push({ text: m[0], match: true });
last = m.index! + m[0].length;
}
if (last < text.length) result.push({ text: text.slice(last), match: false });
return result;
});
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<template v-if="value === null">
<span class="json-null">null</span>
</template>
<template v-else-if="typeof value === 'boolean'">
<span class="json-boolean">{{ String(value) }}</span>
</template>
<template v-else-if="typeof value === 'number'">
<span class="json-number">{{ value }}</span>
</template>
<template v-else-if="typeof value === 'string'">
<span class="json-string">"<JsonText :text="value" :highlight="highlight" />"</span>
</template>
<template v-else-if="Array.isArray(value)">
<template v-if="value.length === 0">
<span>[]</span>
</template>
<template v-else-if="indent < 0">
<span>[</span>
<template v-for="(item, i) in value" :key="i">
<JsonValue :value="item" :indent="indent" :highlight="highlight" />
<span v-if="i < value.length - 1">, </span>
</template>
<span>]</span>
</template>
<template v-else>
<span>[</span>
<template v-for="(item, i) in value" :key="i">
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
<JsonValue :value="item" :indent="indent + 1" :highlight="highlight" />
<span v-if="i < value.length - 1">,</span>
</template>
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
<span>]</span>
</template>
</template>
<template v-else-if="typeof value === 'object'">
<template v-if="entries.length === 0">
<span>{}</span>
</template>
<template v-else-if="indent < 0">
<span>{</span>
<template v-for="([k, v], i) in entries" :key="k">
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
<JsonValue :value="v" :indent="indent" :highlight="highlight" />
<span v-if="i < entries.length - 1">, </span>
</template>
<span>}</span>
</template>
<template v-else>
<span>{</span>
<template v-for="([k, v], i) in entries" :key="k">
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
<JsonValue :value="v" :indent="indent + 1" :highlight="highlight" />
<span v-if="i < entries.length - 1">,</span>
</template>
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
<span>}</span>
</template>
</template>
<template v-else>
<span>{{ String(value) }}</span>
</template>
</template>
<script lang="ts" setup>
import JsonText from "./JsonText.vue";
const {
value,
indent = 0,
highlight,
} = defineProps<{
value: unknown;
indent?: number;
highlight?: string;
}>();
const entries = computed(() =>
value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value as Record<string, unknown>) : [],
);
function pad(level: number): string {
return " ".repeat(Math.max(level, 0));
}
</script>
<style scoped>
@reference "@/main.css";
.json-key {
@apply text-blue;
}
.json-string {
@apply text-green;
}
.json-number {
@apply text-orange;
}
.json-boolean {
@apply text-purple;
}
.json-null {
@apply text-red;
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@
<ph:command v-if="isMac" class="size-4" />
<ph: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>
+5
View File
@@ -19,6 +19,10 @@ async function fetchCloudConfig() {
}
}
// Loaded once at module import (i.e. app boot). Every consumer reads the
// shared `cloudConfig` ref — no per-component fetch.
const initialLoad = fetchCloudConfig();
async function fetchCloudStatus() {
if (!cloudConfig.value?.linked) return;
isLoadingCloudStatus.value = true;
@@ -49,6 +53,7 @@ export function useCloudConfig() {
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
initialLoad,
fetchCloudConfig,
fetchCloudStatus,
clearCloudState,
+155
View File
@@ -0,0 +1,155 @@
import { useCloudConfig } from "@/composable/cloudConfig";
export interface CloudLogHit {
ts: number;
hostId: string;
containerId: string;
containerName: string;
message: string;
stream: string;
level: string;
// Dozzle's deterministic FNV-32a id for the raw log line — used to deep-link
// to the exact line in the local log viewer. Optional: pre-indexing logs
// (or older Dozzle clients) won't have it.
logId?: number;
}
interface CloudLogSearchResponse {
hits: CloudLogHit[];
hasMore: boolean;
// Cursor for the next older page. Pass back as `before=` in the URL.
// Omitted when there's nothing more to load.
nextBefore?: number;
}
const debounceMs = 250;
/**
* useCloudLogSearch performs Cloud-side log search via the Dozzle backend's
* /api/cloud/search/logs endpoint. Identity is derived server-side from the
* authenticated gRPC connection; this composable passes only the query.
*
* Behavior:
* - debounced 250ms; whitespace-only short-circuits to []
* - aborts any in-flight request on each new keystroke (AbortController)
* - `available` is computed: cloud linked AND streamLogs enabled
* - when `available` is false, results stay [] regardless of query
*
* Status mapping:
* 200 -> hits populated (may be empty)
* 204 -> streaming disabled server-side (defense-in-depth)
* 503 -> cloud not configured
* 504 -> timeout (500ms upstream)
* any other 4xx/5xx -> error set, results cleared
*/
export function useCloudLogSearch(query: Ref<string>) {
const { cloudConfig } = useCloudConfig();
const results = ref<CloudLogHit[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref<Error | null>(null);
const hasMore = ref(false);
// Cursor (timestamp_ns) of the last hit on the current page; 0 = at the
// newest page. Cleared on every new query.
const nextBefore = ref<number>(0);
const available = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
// Two parallel fetch lifecycles — keystroke search (cancels on next
// keystroke / unmount) and pagination loadMore (cancels on unmount or
// when a new query lands and supersedes pagination state). Tracking
// them separately avoids the keystroke aborter cancelling an in-flight
// pagination request and vice versa.
let abortController: AbortController | null = null;
let loadMoreAborter: AbortController | null = null;
function clearResults() {
results.value = [];
error.value = null;
loading.value = false;
loadingMore.value = false;
hasMore.value = false;
nextBefore.value = 0;
}
async function fetchPage(q: string, before: number, signal: AbortSignal): Promise<CloudLogSearchResponse | null> {
let url = withBase(`/api/cloud/search/logs?q=${encodeURIComponent(q)}&limit=20`);
if (before > 0) url += `&before=${before}`;
const res = await fetch(url, { signal });
if (res.status === 204) return { hits: [], hasMore: false };
if (!res.ok) throw new Error(`cloud search failed: ${res.status}`);
return (await res.json()) as CloudLogSearchResponse;
}
async function runSearch(q: string) {
if (abortController) abortController.abort();
// A fresh query supersedes any in-flight pagination — that page is
// for the previous query and would be appended onto the wrong result
// set if it landed late.
loadMoreAborter?.abort();
abortController = new AbortController();
loading.value = true;
error.value = null;
nextBefore.value = 0;
try {
const body = await fetchPage(q, 0, abortController.signal);
if (!body) return;
results.value = body.hits ?? [];
hasMore.value = !!body.hasMore;
nextBefore.value = body.nextBefore ?? 0;
} catch (e) {
if ((e as DOMException)?.name !== "AbortError") {
error.value = e as Error;
results.value = [];
hasMore.value = false;
}
} finally {
loading.value = false;
}
}
// loadMore appends the next older page. Safe to call repeatedly — guarded
// by hasMore + a separate loading flag so the input-debounced search and
// the user-triggered pagination don't trip each other.
async function loadMore() {
if (loadingMore.value || !hasMore.value || nextBefore.value <= 0) return;
const q = query.value.trim();
if (!q) return;
loadingMore.value = true;
loadMoreAborter?.abort();
loadMoreAborter = new AbortController();
try {
const body = await fetchPage(q, nextBefore.value, loadMoreAborter.signal);
if (!body) return;
results.value = [...results.value, ...(body.hits ?? [])];
hasMore.value = !!body.hasMore;
nextBefore.value = body.nextBefore ?? 0;
} catch (e) {
if ((e as DOMException)?.name !== "AbortError") error.value = e as Error;
} finally {
loadingMore.value = false;
}
}
watchDebounced(
[query, available],
([q, isAvailable]) => {
const trimmed = q.trim();
if (!isAvailable || trimmed === "") {
clearResults();
return;
}
runSearch(trimmed);
},
{ debounce: debounceMs, immediate: true },
);
onScopeDispose(() => {
abortController?.abort();
loadMoreAborter?.abort();
});
return { results, loading, loadingMore, error, available, hasMore, loadMore };
}
+16
View File
@@ -0,0 +1,16 @@
// Shared open state for the global Cmd+K fuzzy-search modal.
// Lives outside the layout so any surface (home page hero, mobile menu,
// sidebar trigger) can open the same modal without prop-drilling.
const open = ref(false);
export function useFuzzySearch() {
return {
open,
openSearch: () => {
open.value = true;
},
closeSearch: () => {
open.value = false;
},
};
}
+6 -8
View File
@@ -3,7 +3,7 @@
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
<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);
+3
View File
@@ -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;
}
+232
View File
@@ -0,0 +1,232 @@
<template>
<PageWithLinks>
<section>
<!-- Header -->
<div class="mb-5 flex items-center gap-3">
<h2 class="text-lg font-semibold">{{ $t("cloud-search.results-page-title") }}</h2>
<span v-if="committedQuery" class="text-base-content/70 font-mono text-sm">"{{ committedQuery }}"</span>
<span v-if="cloudSearch.available.value" class="status-pill status-pill-primary ml-auto">
<mdi:flash class="size-3" /> {{ $t("cloud-search.hero-pill-indexed") }}
</span>
</div>
<!-- Status line -->
<div class="text-base-content/70 mb-3 flex h-5 items-center gap-2 text-xs">
<template v-if="cloudSearch.loading.value">
<span class="loading loading-spinner loading-xs"></span>
<span>{{ $t("cloud-search.searching") }}</span>
</template>
<template v-else-if="cloudSearch.error.value">
<mdi:alert-circle-outline class="text-error size-3.5" />
<span>{{ $t("cloud-search.search-failed") }}</span>
</template>
<template v-else-if="committedQuery && hits.length === 0">
<span>{{ $t("cloud-search.no-results") }}</span>
</template>
<template v-else-if="!committedQuery">
<span>{{ $t("cloud-search.search-empty-prompt") }}</span>
</template>
<template v-else>
<span class="font-mono">{{ $t("cloud-search.hits-count", { n: hits.length }) }}</span>
<span class="text-base-content/50">{{ $t("cloud-search.window-suffix") }}</span>
</template>
</div>
<!-- Results table matches the visual style of ContainerTable -->
<div v-if="hits.length" class="rounded-box border-base-content/10 overflow-x-auto border">
<table class="table-md md:table-lg table-zebra table">
<thead>
<tr>
<th class="text-base-content/60 w-44 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-time") }}
</th>
<th class="text-base-content/60 w-20 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-level") }}
</th>
<th class="text-base-content/60 w-1 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-container") }}
</th>
<th class="text-base-content/60 text-xs font-medium tracking-wider uppercase">
{{ $t("cloud-search.col-message") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(hit, i) in hits"
:key="`${hit.containerId}-${hit.ts}-${hit.logId ?? 0}-${i}`"
class="hover:bg-primary/5 transition-colors"
:class="{ 'cursor-pointer': isLive(hit) }"
@click="isLive(hit) && openContainer(hit)"
>
<td class="text-base-content/70 font-mono text-xs whitespace-nowrap tabular-nums">
{{ formatTs(hit.ts) }}
</td>
<td>
<span class="status-pill" :class="levelPillClass(hit.level)">{{ hit.level || "info" }}</span>
</td>
<td class="whitespace-nowrap">
<span class="inline-flex items-center gap-2">
<span :class="isLive(hit) ? 'text-base-content' : 'text-base-content/60'">
{{ hit.containerName }}
</span>
<span
v-if="!isLive(hit)"
:title="$t('cloud-search.container-removed')"
class="status-pill status-pill-neutral"
>
{{ $t("cloud-search.container-removed-pill") }}
</span>
</span>
</td>
<td>
<JsonFormatted
v-if="isJson(hit.message)"
:value="hit.message"
:highlight="committedQuery"
class="text-xs"
/>
<span v-else class="font-mono text-xs" v-html="highlight(hit.message, committedQuery)"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="hits.length && (cloudSearch.hasMore.value || cloudSearch.loadingMore.value)"
class="text-base-content/60 mt-4 flex h-10 items-center justify-center text-xs"
>
<span v-if="cloudSearch.loadingMore.value" class="loading loading-spinner loading-xs"></span>
</div>
<!-- Cloud-not-available state -->
<div
v-if="!cloudSearch.available.value && committedQuery"
class="bg-base-200 border-base-content/10 rounded-box border p-8 text-center"
>
<mdi:cloud-off-outline class="text-base-content/40 mx-auto mb-3 size-10" />
<p class="text-base-content/80 text-sm">
{{
cloudConfig?.linked ? $t("cloud-search.enable-streaming-to-search") : $t("cloud-search.connect-to-enable")
}}
</p>
<RouterLink to="/settings/cloud" class="btn btn-primary btn-sm mt-4">
{{ $t("cloud-search.cta-settings") }}
</RouterLink>
</div>
</section>
</PageWithLinks>
</template>
<script lang="ts" setup>
import { useCloudConfig } from "@/composable/cloudConfig";
import { useCloudLogSearch, type CloudLogHit } from "@/composable/cloudLogSearch";
const route = useRoute();
const router = useRouter();
function readQ(q: unknown): string {
return typeof q === "string" ? q : "";
}
const committedQuery = ref(readQ(route.query.q));
const { cloudConfig } = useCloudConfig();
const cloudSearch = useCloudLogSearch(committedQuery);
const hits = computed<CloudLogHit[]>(() => cloudSearch.results.value);
// Look up containers in the live store so we can mark hits whose containers
// have been removed (or never existed for this Dozzle instance) as
// non-clickable. Reactive — if a container is removed mid-session, the
// corresponding row updates instantly.
const containerStore = useContainerStore();
const liveIds = computed(() => new Set(Object.keys(containerStore.allContainersById)));
function isLive(hit: CloudLogHit): boolean {
return liveIds.value.has(hit.containerId);
}
// Infinite scroll: VueUse fires loadMore when the page is scrolled within
// 200px of the bottom. canLoadMore short-circuits both during a fetch and
// when the server reports no more pages, so we don't double-fire.
useInfiniteScroll(document, () => cloudSearch.loadMore(), {
distance: 200,
canLoadMore: () => cloudSearch.hasMore.value && !cloudSearch.loadingMore.value,
});
watch(
() => route.query.q,
(q) => {
committedQuery.value = readQ(q);
},
);
function formatTs(ns: number): string {
const d = new Date(ns / 1e6);
const date = d.toLocaleDateString([], { month: "short", day: "numeric" });
const time = d.toLocaleTimeString([], { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
return `${date} ${time}`;
}
function levelPillClass(level: string): string {
switch ((level || "").toLowerCase()) {
case "error":
case "fatal":
return "status-pill-error";
case "warn":
case "warning":
return "status-pill-warning";
case "info":
return "status-pill-primary";
default:
return "status-pill-neutral";
}
}
// Safe with v-html: escapeHtml runs first, then <mark> tags are added against
// a regex anchored on the (already-escaped) needle. Don't drop the escape
// thinking it's redundant — the message comes from indexed log content.
function highlight(message: string, q: string): string {
if (!q) return escapeHtml(message);
const pattern = q.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
const re = new RegExp(`(${pattern})`, "gi");
return escapeHtml(message).replace(re, '<mark class="bg-warning text-warning-content rounded px-0.5">$1</mark>');
}
function escapeHtml(s: string): string {
return s.replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
}
function isJson(message: string): boolean {
const trimmed = message.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === "object";
} catch {
return false;
}
}
function openContainer(hit: CloudLogHit) {
// Match Dozzle's permanent-link route: /container/:id/time/:datetime?logId=...
// hit.ts is unix nanoseconds; convert to ms then ISO 8601 with millis.
const datetime = new Date(hit.ts / 1e6).toISOString();
const query: Record<string, string> = {};
if (hit.logId !== undefined && hit.logId !== 0) {
// logId pinpoints the exact line; the historical-logs view scrolls to it.
query.logId = String(hit.logId);
}
if (committedQuery.value) {
query.q = committedQuery.value;
}
router.push({
name: "/container/[id].time.[datetime]",
params: { id: hit.containerId, datetime },
query,
});
}
</script>
+13
View File
@@ -44,6 +44,13 @@ declare module 'vue-router/auto-routes' {
{ all: ParamValue<false> },
| 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]'
+1 -1
View File
@@ -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 }) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+8
View File
@@ -51,6 +51,14 @@ type Client struct {
connMu sync.Mutex
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.
+1
View File
@@ -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)
+123
View File
@@ -0,0 +1,123 @@
package cloud
import (
"context"
"errors"
"fmt"
pb "github.com/amir20/dozzle/proto/cloud"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// SearchLogResult is the JSON-friendly response shape returned to the Dozzle
// web layer. Mirrors the proto SearchLogsResponse but lives in this package
// so callers don't have to import the proto package directly.
type SearchLogResult struct {
Hits []SearchLogHit `json:"hits"`
HasMore bool `json:"hasMore"`
// NextBefore is the cursor to pass back as `before` (HTTP) /
// before_ts_ns (gRPC) to fetch the next older page. 0 when HasMore
// is false.
NextBefore int64 `json:"nextBefore,omitempty"`
}
// SearchLogHit is one matched log line, scoped server-side to the connecting
// instance's (user_id, api_key_id) — Cloud derives those from the auth
// metadata, never the request body.
type SearchLogHit struct {
TimestampNs int64 `json:"ts"`
HostID string `json:"hostId"`
ContainerID string `json:"containerId"`
ContainerName string `json:"containerName"`
Message string `json:"message"`
Stream string `json:"stream"`
Level string `json:"level"`
// LogID is Dozzle's FNV-32a hash of the original line. Lets the UI
// build deep-links matching "Copy permalink" output. Omitted when the
// row predates indexing (older Dozzle clients sent 0).
LogID uint32 `json:"logId,omitempty"`
}
// ErrNotConfigured is returned when SearchLogs is called but no Cloud API key
// is available (the user hasn't linked Cloud yet). Callers map this to a 503.
var ErrNotConfigured = errors.New("cloud: no API key configured")
// searchServiceClient returns a (lazily dialed) reusable gRPC client. The
// underlying conn is shared across all SearchLogs calls so we pay the TLS
// handshake once per process — not once per keystroke.
func (c *Client) searchServiceClient() (pb.CloudToolServiceClient, error) {
c.searchConnMu.Lock()
defer c.searchConnMu.Unlock()
if c.searchClient != nil {
return c.searchClient, nil
}
var creds grpc.DialOption
if c.plaintext {
creds = grpc.WithTransportCredentials(insecure.NewCredentials())
} else {
creds = grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))
}
conn, err := grpc.NewClient(c.target, creds)
if err != nil {
return nil, fmt.Errorf("cloud: dial: %w", err)
}
c.searchConn = conn
c.searchClient = pb.NewCloudToolServiceClient(conn)
return c.searchClient, nil
}
// SearchLogs runs a Cloud-side log search against the existing gRPC service.
// Reuses a long-lived gRPC conn (lazily dialed on first call) so the
// 500ms search timeout isn't burned on a TLS handshake per keystroke.
// Identity (user, instance) is enforced server-side from the authenticated
// metadata; this client passes only the per-request fields below.
func (c *Client) SearchLogs(ctx context.Context, query string, limit int32, hostID, containerID string, before int64) (*SearchLogResult, error) {
apiKey := c.apiKeyFunc()
if apiKey == "" {
return nil, ErrNotConfigured
}
client, err := c.searchServiceClient()
if err != nil {
return nil, err
}
mdPairs := []string{"x-api-key", apiKey}
if c.instanceID != "" {
mdPairs = append(mdPairs, "x-instance-id", c.instanceID)
}
callCtx := metadata.NewOutgoingContext(ctx, metadata.Pairs(mdPairs...))
resp, err := client.SearchLogs(callCtx, &pb.SearchLogsRequest{
Query: query,
Limit: limit,
HostId: hostID,
ContainerId: containerID,
BeforeTsNs: before,
})
if err != nil {
return nil, fmt.Errorf("cloud: search: %w", err)
}
hits := make([]SearchLogHit, 0, len(resp.GetHits()))
for _, h := range resp.GetHits() {
hits = append(hits, SearchLogHit{
TimestampNs: h.GetTimestampNs(),
HostID: h.GetHostId(),
ContainerID: h.GetContainerId(),
ContainerName: h.GetContainerName(),
Message: h.GetMessage(),
Stream: h.GetStream(),
Level: h.GetLevel(),
LogID: h.GetLogId(),
})
}
return &SearchLogResult{
Hits: hits,
HasMore: resp.GetHasMore(),
NextBefore: resp.GetNextBeforeTsNs(),
}, nil
}
+12
View File
@@ -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
}
+11 -5
View File
@@ -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) {
+10 -10
View File
@@ -96,11 +96,11 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
// Drop any existing connection so a relink with a new key takes effect
// 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)
}
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/amir20/dozzle/internal/cloud"
"github.com/rs/zerolog/log"
)
// cloudSearchTimeout caps the round-trip to Doligence Cloud. Search is on
// the keystroke path; we'd rather show "no results" than block typing.
const cloudSearchTimeout = 500 * time.Millisecond
// cloudSearchLogs proxies a search query to Doligence Cloud over the existing
// authenticated gRPC connection. Identity is derived server-side from the
// API key — this handler passes neither user nor instance ids.
//
// Status mapping:
// 200 — hits returned (may be empty)
// 204 — streamLogs is disabled; nothing to search
// 503 — cloud not configured (no API key) or no SearchLogs func wired
// 504 — cloud round-trip exceeded the search timeout
// 502 — any other cloud-side error
func (h *handler) cloudSearchLogs(w http.ResponseWriter, r *http.Request) {
if h.config.Cloud.SearchLogs == nil {
writeError(w, http.StatusServiceUnavailable, "cloud not configured")
return
}
cc := h.hostService.CloudConfig()
if cc == nil || !cc.StreamLogsEnabled() {
// Defense in depth — the UI already gates on streamLogs, but a stale
// flag client-side mustn't trigger spurious cloud queries.
w.WriteHeader(http.StatusNoContent)
return
}
q := r.URL.Query().Get("q")
if q == "" {
writeError(w, http.StatusBadRequest, "missing q")
return
}
// Defense in depth: the UI input is short (debounced typing) but a
// malicious client could POST any size. Reject anything past 512
// chars rather than fan it out to Cloud's gRPC backend.
if len(q) > 512 {
writeError(w, http.StatusBadRequest, "q too long")
return
}
// Cloud caps server-side at 50; mirror it here so a misbehaving client
// can't tie up the keystroke path with an oversized request. ParseInt
// with bitSize=32 guarantees the value fits in int32, so the cast is
// provably safe (out-of-range parses return an error and fall through).
limit := int32(20)
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.ParseInt(v, 10, 32); err == nil && n > 0 {
if n > 50 {
n = 50
}
limit = int32(n)
}
}
hostID := r.URL.Query().Get("hostId")
containerID := r.URL.Query().Get("containerId")
// Pagination cursor — pass-through to Cloud. 0 (the default) means
// "newest"; subsequent pages send back the prior response's nextBefore.
var before int64
if v := r.URL.Query().Get("before"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
before = n
}
}
ctx, cancel := context.WithTimeout(r.Context(), cloudSearchTimeout)
defer cancel()
result, err := h.config.Cloud.SearchLogs(ctx, q, limit, hostID, containerID, before)
if err != nil {
if errors.Is(err, cloud.ErrNotConfigured) {
writeError(w, http.StatusServiceUnavailable, "cloud not configured")
return
}
if errors.Is(err, context.DeadlineExceeded) {
writeError(w, http.StatusGatewayTimeout, "cloud search timed out")
return
}
log.Warn().Err(err).Msg("cloud search failed")
writeError(w, http.StatusBadGateway, "cloud search failed")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
+19 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"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)
+28
View File
@@ -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"
+28
View File
@@ -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"
+28
View File
@@ -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
+28
View File
@@ -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! 🙏🏼
+28
View File
@@ -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é"
+28
View File
@@ -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"
+28
View File
@@ -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"
+28
View File
@@ -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: "삭제됨"
+28
View File
@@ -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"
+28
View File
@@ -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"
+28
View File
@@ -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"
+28
View File
@@ -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"
+28
View File
@@ -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: "удалён"
+28
View File
@@ -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"
+28
View File
@@ -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"
+28
View File
@@ -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: "已刪除"
+28
View File
@@ -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: "已删除"
+7 -4
View File
@@ -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")
+308 -16
View File
@@ -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,
},
+48 -1
View File
@@ -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",
+51
View File
@@ -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;
}