fix: datasources UI fix (#13286)

This commit is contained in:
Bhargav
2026-07-20 11:21:59 +00:00
committed by GitHub
parent 9063b4c943
commit c29994ac20
39 changed files with 2580 additions and 2905 deletions
@@ -20,6 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-model="ingestTabType"
:tabs="recommendedTabs"
:splitter-width="270"
searchable
search-data-test="recommended-list-search-input"
panel-data-test="data-sources-recommended-tabs"
tab-data-test-prefix="ingestion-recommended-tab-"
>
@@ -18,6 +18,7 @@ import { mount, flushPromises } from "@vue/test-utils";
import { createStore } from "vuex";
import AWSConfig from "./AWSConfig.vue";
import { createI18n } from 'vue-i18n';
import enUS from '@/locales/languages/en-US.json';
import { nextTick } from 'vue';
// Mock dependencies
@@ -103,10 +104,12 @@ const mockStore = createStore({
});
// Create mock i18n
// Real locale messages: the component renders t() keys, and several
// assertions below check the resulting English text.
const mockI18n = createI18n({
locale: 'en',
messages: {
en: {}
en: enUS
}
});
@@ -255,9 +258,8 @@ describe("AWSConfig", () => {
expect(content).toContain('Access Key: [BASIC_PASSCODE]');
});
// Test 15: AWSQuickSetup component integration (replaces AWSIntegrationGrid)
it("should render AWSIntegrationGrid component", () => {
// Component now uses AWSQuickSetup and AWSIndividualServices instead of AWSIntegrationGrid
// Test 15: quick-setup panel is mounted
it("should render AWSQuickSetup component", () => {
const quickSetup = wrapper.findComponent({ name: 'AWSQuickSetup' });
expect(quickSetup.exists()).toBe(true);
});
@@ -17,11 +17,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="m-3 mt-1">
<div class="mb-4">
<div data-test="aws-config-page-title" class="text-2xl font-semibold leading-[1.3] m-0 mb-1.5" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#1a1a1a]'">
AWS Integrations
<div
data-test="aws-config-page-title"
class="text-2xl font-semibold leading-tight m-0 mb-1.5 text-text-heading"
>
{{ t("ingestion.awsSetup.title") }}
</div>
<div data-test="aws-config-page-description" class="text-sm m-0 mb-4" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
Set up AWS monitoring in one click or configure individual services for granular control.
<div
data-test="aws-config-page-description"
class="text-sm m-0 mb-4 text-text-secondary"
>
{{ t("ingestion.awsSetup.description") }}
</div>
<OTabs
@@ -30,8 +36,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="aws-tabs"
align="left"
>
<OTab name="quick-setup" label="Quick Setup" data-test="aws-quick-setup-tab" />
<OTab name="individual-services" label="Individual Services" data-test="aws-individual-services-tab" />
<OTab
name="quick-setup"
:label="t('ingestion.awsSetup.quickSetup')"
data-test="aws-quick-setup-tab"
/>
<OTab
name="individual-services"
:label="t('ingestion.awsSetup.individualServices')"
data-test="aws-individual-services-tab"
/>
</OTabs>
</div>
@@ -49,11 +63,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="mt-8">
<div class="mb-3">
<div class="text-base font-semibold m-0" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#333]'">
Manual Configuration
<div class="text-base font-semibold m-0 text-text-heading">
{{ t("ingestion.awsSetup.manualTitle") }}
</div>
<div class="text-sm m-0" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
Use these credentials for custom AWS integrations or manual setup.
<div class="text-sm m-0 text-text-secondary">
{{ t("ingestion.awsSetup.manualDescription") }}
</div>
</div>
<CopyContent :content="content" />
@@ -68,6 +82,7 @@ import OTabPanels from '@/lib/navigation/Tabs/OTabPanels.vue'
import OTabPanel from '@/lib/navigation/Tabs/OTabPanel.vue'
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import { defineComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import config from "../../../aws-exports";
import { useStore } from "vuex";
@@ -98,6 +113,7 @@ export default defineComponent({
AWSIndividualServices,
},
setup(props) {
const { t } = useI18n();
const store = useStore();
const route = useRoute();
@@ -137,6 +153,7 @@ export default defineComponent({
Access Key: [BASIC_PASSCODE]`;
return {
t,
store,
config,
endpoint,
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import enUS from '@/locales/languages/en-US.json';
import { createRouter, createWebHistory } from 'vue-router';
import { nextTick } from 'vue';
import AWSIndividualServices from './AWSIndividualServices.vue';
@@ -76,7 +77,9 @@ const mockStore = createStore({
},
});
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
// Real locale messages: the component renders t() keys, and several
// assertions below check the resulting English text.
const mockI18n = createI18n({ locale: 'en', messages: { en: enUS } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="mb-4">
<OSearchInput
v-model="searchQuery"
placeholder="Search AWS services..."
:placeholder="t('ingestion.awsSetup.searchPlaceholder')"
clearable
class="max-w-md"
data-test="aws-integration-search"
@@ -30,26 +30,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTabs
v-model="activeCategory"
dense
class="text-gray-400"
data-test="aws-integration-category-tabs"
>
<OTab name="all" label="All Services" />
<OTab name="logs" label="Logs" />
<OTab name="metrics" label="Metrics" />
<OTab name="security" label="Security" />
<OTab name="networking" label="Networking" />
<OTab name="all" :label="t('ingestion.awsSetup.categoryAll')" />
<OTab name="logs" :label="t('ingestion.awsSetup.categoryLogs')" />
<OTab name="metrics" :label="t('ingestion.awsSetup.categoryMetrics')" />
<OTab name="security" :label="t('ingestion.awsSetup.categorySecurity')" />
<OTab
name="networking"
:label="t('ingestion.awsSetup.categoryNetworking')"
/>
</OTabs>
</div>
<div
v-if="filteredIntegrations.length === 0"
class="text-center py-12 text-[#666] dark:text-[#999]"
class="text-center py-12 text-text-secondary"
>
<OIcon name="search-off" class="mb-2" style="width: 3rem; height: 3rem;" />
<div class="text-base">No integrations found matching your search</div>
<OIcon name="search-off" class="mb-2 w-12 h-12" />
<div class="text-base">{{ t("ingestion.awsSetup.noResults") }}</div>
</div>
<div class="integrations-grid grid grid-cols-4 gap-4" v-else>
<!-- Responsive columns via utilities — replaces an unscoped <style> block
whose px media queries leaked to every page in the app. -->
<div
class="integrations-grid grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
v-else
>
<AWSIntegrationTile
v-for="integration in filteredIntegrations"
:key="integration.id"
@@ -64,6 +71,7 @@ import OTabs from '@/lib/navigation/Tabs/OTabs.vue'
import OTab from '@/lib/navigation/Tabs/OTab.vue'
import OSearchInput from '@/lib/forms/SearchInput/OSearchInput.vue'
import { defineComponent, ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { awsIntegrations } from "@/utils/awsIntegrations";
import AWSIntegrationTile from "./AWSIntegrationTile.vue";
@@ -84,6 +92,7 @@ export default defineComponent({
},
},
setup(props) {
const { t } = useI18n();
const route = useRoute();
const searchQuery = ref((route.query.search as string) || props.initialSearch || "");
const activeCategory = ref("all");
@@ -133,6 +142,7 @@ export default defineComponent({
});
return {
t,
searchQuery,
activeCategory,
filteredIntegrations,
@@ -140,24 +150,3 @@ export default defineComponent({
},
});
</script>
<style>
@media (max-width: 1200px) {
.aws-integration-grid .integrations-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.aws-integration-grid .integrations-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.aws-integration-grid .integrations-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,261 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import { createRouter, createWebHistory } from 'vue-router';
import { nextTick } from 'vue';
import AWSIntegrationGrid from './AWSIntegrationGrid.vue';
vi.mock('../../../aws-exports', () => ({
default: { API_ENDPOINT: 'http://localhost:5080', region: 'us-east-1' },
}));
vi.mock('@/utils/zincutils', () => ({
getEndPoint: vi.fn(() => ({ url: 'http://localhost:5080', host: 'localhost', port: '5080', protocol: 'http', tls: false })),
getIngestionURL: vi.fn(() => 'http://localhost:5080'),
}));
vi.mock('@/utils/awsIntegrations', () => ({
awsIntegrations: [
{
id: 'cloudwatch-logs',
name: 'CloudWatchLogs',
displayName: 'CloudWatch Logs',
description: 'Collect CloudWatch logs',
category: 'logs',
cloudFormationTemplate: 'https://example.com/cloudwatch.yaml',
hasDashboard: true,
},
{
id: 's3-events',
name: 'S3Events',
displayName: 'S3 Events',
description: 'Collect S3 bucket events',
category: 'logs',
cloudFormationTemplate: 'https://example.com/s3.yaml',
hasDashboard: false,
},
{
id: 'vpc-flow',
name: 'VPCFlow',
displayName: 'VPC Flow Logs',
description: 'Collect VPC flow logs',
category: 'networking',
hasDashboard: false,
},
{
id: 'guardduty',
name: 'GuardDuty',
displayName: 'GuardDuty',
description: 'AWS GuardDuty findings',
category: 'security',
cloudFormationTemplate: 'https://example.com/guardduty.yaml',
hasDashboard: true,
},
],
generateCloudFormationURL: vi.fn(() => 'https://console.aws.amazon.com/cloudformation'),
}));
vi.mock('@/services/segment_analytics', () => ({
default: { track: vi.fn() },
}));
vi.mock('./AWSIntegrationTile.vue', () => ({
default: {
name: 'AWSIntegrationTile',
template: '<div class="aws-tile" :data-id="integration.id">{{ integration.displayName }}</div>',
props: ['integration'],
},
}));
const mockStore = createStore({
state: {
selectedOrganization: { identifier: 'test-org' },
userInfo: { email: 'test@example.com' },
organizationData: { organizationPasscode: 'test-passcode' },
},
});
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
});
describe('AWSIntegrationGrid.vue', () => {
let wrapper: VueWrapper;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
if (wrapper) wrapper.unmount();
});
const createWrapper = (props = {}) => {
return mount(AWSIntegrationGrid, {
props,
global: {
plugins: [mockI18n, mockRouter],
provide: { store: mockStore },
},
});
};
describe('Component Rendering', () => {
it('should mount without errors', () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it('should unmount without errors', () => {
wrapper = createWrapper();
expect(() => wrapper.unmount()).not.toThrow();
});
it('should render search input', () => {
wrapper = createWrapper();
const searchInput = wrapper.find('[data-test="aws-integration-search"]');
expect(searchInput.exists()).toBe(true);
});
it('should render category tabs', () => {
wrapper = createWrapper();
const tabs = wrapper.find('[data-test="aws-integration-category-tabs"]');
expect(tabs.exists()).toBe(true);
});
it('should render All Services tab', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('All Services');
});
it('should render Logs, Metrics, Security, Networking tabs', () => {
wrapper = createWrapper();
const text = wrapper.text();
expect(text).toContain('Logs');
expect(text).toContain('Metrics');
expect(text).toContain('Security');
expect(text).toContain('Networking');
});
it('should render integration tiles', () => {
wrapper = createWrapper();
const tiles = wrapper.findAll('.aws-tile');
expect(tiles.length).toBeGreaterThan(0);
});
});
describe('Component Identity', () => {
it('should have correct component name', () => {
wrapper = createWrapper();
expect(wrapper.vm.$options.name).toBe('AWSIntegrationGrid');
});
});
describe('Props', () => {
it('should accept initialSearch prop', () => {
wrapper = createWrapper({ initialSearch: 'CloudWatch' });
expect(wrapper.props('initialSearch')).toBe('CloudWatch');
});
it('should initialize searchQuery from initialSearch prop', () => {
wrapper = createWrapper({ initialSearch: 'VPC' });
const vm = wrapper.vm as any;
expect(vm.searchQuery).toBe('VPC');
});
});
describe('searchQuery Reactive Data', () => {
it('should have empty searchQuery by default', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.searchQuery).toBe('');
});
it('should have activeCategory as "all" by default', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCategory).toBe('all');
});
});
describe('filteredIntegrations Computed', () => {
it('should return all integrations when no filter', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.filteredIntegrations.length).toBe(4);
});
it('should filter by search query (displayName)', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.searchQuery = 'CloudWatch';
await nextTick();
expect(vm.filteredIntegrations.length).toBe(1);
expect(vm.filteredIntegrations[0].displayName).toBe('CloudWatch Logs');
});
it('should filter by search query (name)', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.searchQuery = 'VPCFlow';
await nextTick();
expect(vm.filteredIntegrations.length).toBe(1);
});
it('should filter by category', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.activeCategory = 'logs';
await nextTick();
const logsServices = vm.filteredIntegrations.filter((i: any) => i.category === 'logs');
expect(logsServices.length).toBe(vm.filteredIntegrations.length);
});
it('should filter by security category', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.activeCategory = 'security';
await nextTick();
expect(vm.filteredIntegrations.length).toBe(1);
expect(vm.filteredIntegrations[0].displayName).toBe('GuardDuty');
});
it('should show empty state when no matches', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.searchQuery = 'nonexistent-service-xyz';
await nextTick();
expect(vm.filteredIntegrations.length).toBe(0);
});
it('should sort results alphabetically', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
const names = vm.filteredIntegrations.map((i: any) => i.displayName);
const sorted = [...names].sort();
expect(names).toEqual(sorted);
});
it('should filter case insensitively', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.searchQuery = 'cloudwatch';
await nextTick();
expect(vm.filteredIntegrations.length).toBe(1);
});
});
describe('Empty State', () => {
it('should show empty state message when no integrations match', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.searchQuery = 'zzznomatch';
await nextTick();
expect(wrapper.text()).toContain('No integrations found');
});
});
});
@@ -1,143 +0,0 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="w-full">
<div class="mb-4">
<OSearchInput
v-model="searchQuery"
placeholder="Search AWS services..."
clearable
class="max-w-md"
data-test="aws-integration-search"
/>
</div>
<div class="mb-6">
<OTabs
v-model="activeCategory"
dense
class="text-gray-400"
data-test="aws-integration-category-tabs"
>
<OTab name="all" label="All Services" />
<OTab name="logs" label="Logs" />
<OTab name="metrics" label="Metrics" />
<OTab name="security" label="Security" />
<OTab name="networking" label="Networking" />
</OTabs>
</div>
<div
v-if="filteredIntegrations.length === 0"
class="text-center py-12 text-[#666] dark:text-[#999]"
>
<OIcon name="search-off" class="mb-2" style="width: 3rem; height: 3rem;" />
<div class="text-base">No integrations found matching your search</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" v-else>
<div
v-for="integration in filteredIntegrations"
:key="integration.id"
>
<AWSIntegrationTile :integration="integration" />
</div>
</div>
</div>
</template>
<script lang="ts">
import OTabs from '@/lib/navigation/Tabs/OTabs.vue'
import OTab from '@/lib/navigation/Tabs/OTab.vue'
import OSearchInput from '@/lib/forms/SearchInput/OSearchInput.vue'
import { defineComponent, ref, computed, watch } from "vue";
import { useRoute } from "vue-router";
import { awsIntegrations } from "@/utils/awsIntegrations";
import AWSIntegrationTile from "./AWSIntegrationTile.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
export default defineComponent({
name: "AWSIntegrationGrid",
components: {
OTabs, OTab,
AWSIntegrationTile,
OSearchInput,
OIcon,
},
props: {
initialSearch: {
type: String,
default: "",
},
},
setup(props) {
const route = useRoute();
const searchQuery = ref((route.query.search as string) || props.initialSearch || "");
const activeCategory = ref("all");
// Watch for changes in route query parameter
watch(() => route.query.search, (newSearch) => {
if (newSearch && typeof newSearch === 'string') {
searchQuery.value = newSearch;
} else if (newSearch === undefined) {
// Clear search when query param is removed
searchQuery.value = "";
}
});
// Watch for changes in initialSearch prop
watch(() => props.initialSearch, (newSearch) => {
if (newSearch !== undefined) {
searchQuery.value = newSearch;
}
});
const filteredIntegrations = computed(() => {
let filtered = [...awsIntegrations];
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(
(integration) =>
integration.displayName.toLowerCase().includes(query) ||
integration.description.toLowerCase().includes(query) ||
integration.name.toLowerCase().includes(query)
);
}
// Filter by category
if (activeCategory.value !== "all") {
filtered = filtered.filter(
(integration) => integration.category === activeCategory.value
);
}
// Sort by name
filtered.sort((a, b) => a.displayName.localeCompare(b.displayName));
return filtered;
});
return {
searchQuery,
activeCategory,
filteredIntegrations,
};
},
});
</script>
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import enUS from '@/locales/languages/en-US.json';
import { createRouter, createWebHistory } from 'vue-router';
import AWSIntegrationTile from './AWSIntegrationTile.vue';
@@ -107,7 +108,9 @@ const mockStore = createStore({
},
});
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
// Real locale messages: the component renders t() keys, and several
// assertions below check the resulting English text.
const mockI18n = createI18n({ locale: 'en', messages: { en: enUS } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [
@@ -16,11 +16,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<OCard
class="border border-border h-full flex flex-col transition-all duration-200 ease-in-out rounded-lg hover:-translate-y-0.5 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(255,255,255,0.1)]"
class="border border-border h-full flex flex-col transition-all duration-200 ease-in-out rounded-lg hover:-translate-y-0.5 hover:shadow-lg"
>
<OCardSection class="p-4 pb-2 flex-1">
<div class="flex items-start justify-between mb-2">
<div class="font-semibold text-base leading-[1.4]" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#1a1a1a]'">
<div class="font-semibold text-base leading-snug text-text-heading">
{{ integration.displayName }}
</div>
<OButton
@@ -32,10 +32,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`aws-${integration.id}-docs-btn`"
>
<OIcon name="description" size="sm" />
<OTooltip content="View Documentation" />
<OTooltip :content="t('ingestion.awsSetup.viewDocumentation')" />
</OButton>
</div>
<div class="text-sm mb-3 leading-normal min-h-[3em]" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="text-sm mb-3 leading-normal min-h-[3em] text-text-secondary">
{{ integration.description }}
</div>
</OCardSection>
@@ -49,7 +49,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="handleAddSource()"
class="flex-1"
:data-test="`aws-${integration.id}-add-source-btn`"
>Add Source</OButton
>{{ t("ingestion.awsSetup.addSource") }}</OButton
>
<!-- Documentation Button (only shown if no CloudFormation) -->
<OButton
@@ -59,7 +59,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="handleDocumentation()"
class="flex-1"
:data-test="`aws-${integration.id}-documentation-btn`"
>Documentation</OButton
>{{ t("ingestion.awsSetup.documentation") }}</OButton
>
<!-- Add Dashboard Button -->
<OButton
@@ -69,17 +69,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:disabled="!integration.hasDashboard || !integration.dashboardGithubUrl"
class="flex-1"
:data-test="`aws-${integration.id}-add-dashboard-btn`"
>Add Dashboard</OButton
>{{ t("ingestion.awsSetup.addDashboard") }}</OButton
>
</OCardActions>
<!-- Unified Integration Method Selection Dialog -->
<ODialog data-test="aws-integration-tile-template-dialog" v-model:open="showTemplateDialog" size="sm" title="Choose Integration Method"
secondary-button-label="Cancel"
<ODialog
data-test="aws-integration-tile-template-dialog"
v-model:open="showTemplateDialog"
size="sm"
:title="t('ingestion.awsSetup.chooseMethod')"
:secondary-button-label="t('common.cancel')"
@click:secondary="showTemplateDialog = false"
>
<div class="text-sm font-medium mb-3">
Select how you want to integrate {{ integration.displayName }}:
{{
t("ingestion.awsSetup.chooseMethodPrompt", {
service: integration.displayName,
})
}}
</div>
<ul class="aws-integration-options-list flex flex-col list-none p-0 m-0">
<!-- CloudFormation Templates -->
@@ -135,6 +143,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
import { defineComponent, type PropType, ref, computed, shallowRef } from "vue";
import { useI18n } from "vue-i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
@@ -171,6 +180,7 @@ export default defineComponent({
},
},
setup(props) {
const { t } = useI18n();
const store = useStore();
const { confirm } = useConfirmDialog();
const router = useRouter();
@@ -563,6 +573,7 @@ export default defineComponent({
};
return {
t,
store,
handleAddSource,
handleAddDashboard,
@@ -23,7 +23,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon
name="rocket-launch"
size="xl"
class="text-[var(--q-primary)]"
class="text-text-link"
/>
<div>
<h6 class="text-xl! font-bold m-0 mb-2!">
@@ -121,7 +121,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:options="AWS_REGIONS"
valueKey="value"
labelKey="label"
style="max-width: 320px"
class="max-w-xs"
data-test="aws-region-select"
/>
</div>
@@ -131,7 +131,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="mb-6">
<div class="mb-3 font-semibold text-[0.9rem]" :class="stepLabelClass">
Admin region
<span class="font-normal text-xs text-[#888]"
<span class="font-normal text-xs text-text-muted"
>(where the StackSet is managed)</span
>
</div>
@@ -140,7 +140,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:options="AWS_REGIONS"
valueKey="value"
labelKey="label"
style="max-width: 320px"
class="max-w-xs"
data-test="aws-admin-region-select"
/>
</div>
@@ -158,7 +158,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<div class="font-semibold text-[0.9rem]" :class="stepLabelClass">
Target regions
<span class="font-normal text-xs text-[#888]"
<span class="font-normal text-xs text-text-muted"
>(where stacks will be deployed)</span
>
</div>
@@ -261,7 +261,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<span
v-if="enabledServices.length === 0"
class="text-sm text-red-500"
class="text-sm text-status-error-text"
>
Select at least one service
</span>
@@ -269,7 +269,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-else-if="
deploymentMode === 'stackset' && targetRegions.length === 0
"
class="text-sm text-red-500"
class="text-sm text-status-error-text"
>
Select at least one target region
</span>
@@ -311,14 +311,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
The StackSets console doesn't support URL pre-fill. Enter these
values as you go through the wizard.
</p>
<div class="flex flex-col gap-[6px]">
<div class="flex flex-col gap-1.5">
<div
v-for="param in stackSetParams"
:key="param.key"
class="flex items-center gap-3 py-[6px] px-[10px] rounded text-[0.8rem] font-mono"
class="flex items-center gap-3 py-1.5 px-2.5 rounded text-xs font-mono"
:class="paramRowClass"
>
<div class="min-w-[240px] font-semibold shrink-0" :class="paramKeyClass">{{ param.key }}</div>
<div class="min-w-60 font-semibold shrink-0" :class="paramKeyClass">{{ param.key }}</div>
<div class="flex items-center gap-1 flex-1 overflow-hidden">
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-1" :class="paramValTextClass">{{ param.value }}</span>
<OButton
@@ -399,37 +399,19 @@ export default defineComponent({
const showTargetRegions = ref(false);
const showServices = ref(false);
const quickInstallBgClass = computed(() => {
return store.state.theme === 'dark'
? 'bg-gray-800 border border-gray-700'
: 'bg-blue-50 border border-blue-200';
});
const descriptionClass = computed(() => {
return store.state.theme === 'dark' ? 'text-gray-300' : 'text-gray-700';
});
// dark: variants do not generate CSS in this setup, so dark-mode colors
// are resolved here against store.state.theme (same pattern as quickInstallBgClass).
const isDark = computed(() => store.state.theme === 'dark');
const stepLabelClass = computed(() =>
isDark.value ? 'text-[#d0d0d0]' : 'text-[#333]');
const hintTextClass = computed(() =>
isDark.value ? 'text-[#b0b0b0]' : 'text-[#666]');
const collapsibleHeaderClass = computed(() =>
isDark.value
? 'bg-[rgba(255,255,255,0.06)] border border-[#404040] hover:bg-[rgba(255,255,255,0.09)]'
: 'bg-[#f0f4ff] border border-[#d0d9f0] hover:bg-[#e8eeff]');
const paramHelperClass = computed(() =>
isDark.value ? 'bg-[rgba(255,255,255,0.05)]' : 'bg-[#f5f5f5]');
const paramRowClass = computed(() =>
isDark.value
? 'bg-[rgba(255,255,255,0.03)] border border-[#404040]'
: 'bg-white border border-(--o2-border)');
const paramKeyClass = computed(() =>
isDark.value ? 'text-[#ccc]' : 'text-[#333]');
const paramValTextClass = computed(() =>
isDark.value ? 'text-[#aaa]' : 'text-[#555]');
// Design tokens resolve per-theme on their own, so none of these branch on
// store.state.theme any more — they were eight computeds returning hardcoded
// hex/palette colors (plus a banned --o2-border) for exactly that reason.
const quickInstallBgClass = "bg-status-info-bg border border-border-default";
const descriptionClass = "text-text-secondary";
const stepLabelClass = "text-text-heading";
const hintTextClass = "text-text-secondary";
const collapsibleHeaderClass =
"bg-surface-subtle border border-border-default hover:bg-surface-subtle-hover";
const paramHelperClass = "bg-surface-subtle";
const paramRowClass = "bg-surface-base border border-border-default";
const paramKeyClass = "text-text-heading";
const paramValTextClass = "text-text-secondary";
let endpoint: any = null;
try {
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import enUS from '@/locales/languages/en-US.json';
import { createRouter, createWebHistory } from 'vue-router';
import AzureConfig from './AzureConfig.vue';
@@ -45,7 +46,9 @@ const mockStore = createStore({
},
});
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
// Real locale messages: the component renders t() keys, and several
// assertions below check the resulting English text.
const mockI18n = createI18n({ locale: 'en', messages: { en: enUS } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
@@ -58,6 +61,16 @@ describe('AzureConfig.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('open', vi.fn());
// afterEach's unstubAllGlobals also clears the ResizeObserver stub that
// setupTests installs globally, and OTabs needs it (jsdom has none).
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
});
afterEach(() => {
@@ -15,45 +15,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="m-3 mt-1 max-w-[860px]">
<div class="m-3 mt-1 max-w-4xl">
<!-- Header -->
<div class="flex items-start gap-4 mb-6">
<OIcon
name="cloud"
size="xl"
class="flex-shrink-0"
/>
<OIcon name="cloud" size="xl" class="flex-shrink-0" />
<div>
<div class="text-sm font-medium m-0 mb-1" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#1a1a1a]'">
Azure Activity Logs
<div
data-test="azure-config-page-title"
class="text-sm font-medium m-0 mb-1 text-text-heading"
>
{{ t("ingestion.azureSetup.activityLogsTitle") }}
</div>
<div class="text-sm m-0" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
Stream Azure subscription activity logs to OpenObserve via Event Hub.
The ARM template sets up the Event Hub infrastructure — you then
configure Azure to export logs to it.
<div class="text-sm m-0 text-text-secondary">
{{ t("ingestion.azureSetup.activityLogsDescription") }}
</div>
</div>
</div>
<!-- Step 1 -->
<div
class="mb-4 p-4 rounded border-l-[3px] border-l-solid"
:class="store.state.theme === 'dark' ? 'bg-[rgba(255,255,255,0.04)] border-l-[#404040]' : 'bg-[#fafafa] border-l-[#e0e0e0]'"
class="mb-4 p-4 rounded border-l-4 border-l-solid bg-surface-subtle border-l-border-strong"
>
<div
style="
display: grid;
grid-template-columns: 28px 1fr;
gap: 12px;
align-items: start;
"
>
<div class="w-7 h-7 rounded-full flex items-center justify-center font-bold text-[0.85rem] shrink-0 bg-[#1976d2] text-white">1</div>
<div class="flex gap-3 items-start">
<div class="w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shrink-0 bg-status-info-bg text-status-info-text">1</div>
<div>
<div class="font-semibold mb-1" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#1a1a1a]'">
<div class="font-semibold mb-1 text-text-heading">
Deploy ARM Template
</div>
<div class="text-sm m-0 mb-3" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="text-sm m-0 mb-3 text-text-secondary">
Creates an Event Hub namespace, Event Hub, and all required
resources in your Azure subscription.
</div>
@@ -74,23 +63,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Step 2 -->
<div
class="mb-4 p-4 rounded border-l-[3px] border-l-solid"
:class="store.state.theme === 'dark' ? 'bg-[rgba(255,255,255,0.04)] border-l-[#404040]' : 'bg-[#fafafa] border-l-[#e0e0e0]'"
class="mb-4 p-4 rounded border-l-4 border-l-solid bg-surface-subtle border-l-border-strong"
>
<div
style="
display: grid;
grid-template-columns: 28px 1fr;
gap: 12px;
align-items: start;
"
>
<div class="w-7 h-7 rounded-full flex items-center justify-center font-bold text-[0.85rem] shrink-0 bg-[#1976d2] text-white">2</div>
<div class="flex gap-3 items-start">
<div class="w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shrink-0 bg-status-info-bg text-status-info-text">2</div>
<div>
<div class="font-semibold mb-1" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#1a1a1a]'">
<div class="font-semibold mb-1 text-text-heading">
Configure Diagnostic Settings
</div>
<div class="text-sm mb-3" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="text-sm mb-3 text-text-secondary">
After the ARM deployment completes, route Activity Logs to the Event
Hub that was created.
</div>
@@ -103,7 +84,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Portal instructions -->
<div v-if="step2Mode === 'portal'">
<ol class="text-sm pl-4 space-y-1" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<ol class="text-sm pl-4 space-y-1 text-text-secondary">
<li>
Go to
<strong
@@ -133,7 +114,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Categories -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs font-semibold" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="text-xs font-semibold text-text-heading">
Log categories to enable
</div>
<div class="flex gap-2">
@@ -153,14 +134,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
</div>
</div>
<div
style="
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
width: 100%;
"
>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 w-full">
<OCheckbox
v-for="cat in LOG_CATEGORIES"
:key="cat.value"
@@ -171,16 +145,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<div
style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
"
>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<div class="text-xs mb-1" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="text-xs mb-1 text-text-heading">
Resource Group
</div>
<OInput
@@ -191,7 +158,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<div>
<div class="text-xs mb-1" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="text-xs mb-1 text-text-heading">
Deployment Name
</div>
<OInput
@@ -205,12 +172,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-if="enabledCategories.length === 0"
class="text-sm text-red-500 mb-3"
class="text-sm text-status-error-text mb-3"
>
Select at least one log category above.
</div>
<div v-else>
<div class="text-xs mb-2" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="text-xs mb-2 text-text-secondary">
Run this command after your ARM deployment completes:
</div>
<CopyContent
@@ -225,8 +192,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Manual Configuration -->
<div class="mt-6">
<div class="font-semibold text-sm mb-2" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
Manual Configuration (for reference)
<div class="font-semibold text-sm mb-2 text-text-heading">
{{ t("ingestion.azureSetup.manualTitle") }}
</div>
<CopyContent :content="manualContent" />
</div>
@@ -235,6 +202,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
import { defineComponent, computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
@@ -271,10 +239,12 @@ const activityLogsIntegration = azureIntegrations.find(
export default defineComponent({
name: "AzureConfig",
components: { CopyContent, OToggleGroup, OToggleGroupItem, OButton, OCheckbox, OInput,
components: {
CopyContent, OToggleGroup, OToggleGroupItem, OButton, OCheckbox, OInput,
OIcon,
},
},
setup() {
const { t } = useI18n();
const store = useStore();
let endpoint: any = null;
@@ -347,6 +317,7 @@ export default defineComponent({
};
return {
t,
store,
LOG_CATEGORIES,
step2Mode,
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import enUS from '@/locales/languages/en-US.json';
import { createRouter, createWebHistory } from 'vue-router';
import { nextTick } from 'vue';
import AzureIndividualServices from './AzureIndividualServices.vue';
@@ -69,7 +70,9 @@ const mockStore = createStore({
},
});
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
// Real locale messages: the component renders t() keys, and several
// assertions below check the resulting English text.
const mockI18n = createI18n({ locale: 'en', messages: { en: enUS } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="mb-4">
<OSearchInput
v-model="searchQuery"
placeholder="Search Azure services..."
:placeholder="t('ingestion.azureSetup.searchPlaceholder')"
clearable
class="max-w-md"
data-test="azure-integration-search"
@@ -30,34 +30,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTabs
v-model="activeCategory"
dense
class="text-gray-400"
data-test="azure-integration-category-tabs"
>
<OTab name="all" label="All Services" />
<OTab name="logs" label="Logs" />
<OTab name="compute" label="Compute" />
<OTab name="storage" label="Storage" />
<OTab name="security" label="Security" />
<OTab name="networking" label="Networking" />
<OTab name="all" :label="t('ingestion.azureSetup.categoryAll')" />
<OTab name="logs" :label="t('ingestion.azureSetup.categoryLogs')" />
<OTab name="compute" :label="t('ingestion.azureSetup.categoryCompute')" />
<OTab name="storage" :label="t('ingestion.azureSetup.categoryStorage')" />
<OTab name="security" :label="t('ingestion.azureSetup.categorySecurity')" />
<OTab name="networking" :label="t('ingestion.azureSetup.categoryNetworking')" />
</OTabs>
</div>
<div
v-if="filteredIntegrations.length === 0"
class="text-center py-12 text-[#666] dark:text-[#999]"
class="text-center py-12 text-text-secondary"
>
<OIcon name="search-off" class="mb-2" style="width: 3rem; height: 3rem;" />
<div class="text-base">No integrations found matching your search</div>
<OIcon name="search-off" class="mb-2 w-12 h-12" />
<div class="text-base">{{ t("ingestion.azureSetup.noResults") }}</div>
</div>
<div class="flex gap-3" v-else>
<div
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
v-else
>
<AzureIntegrationTile
v-for="integration in filteredIntegrations"
:key="integration.id"
class="w-full col-sm-6 col-md-4 col-lg-3"
>
<AzureIntegrationTile :integration="integration" />
</div>
:integration="integration"
/>
</div>
</div>
</template>
@@ -67,6 +67,7 @@ import OTabs from '@/lib/navigation/Tabs/OTabs.vue'
import OTab from '@/lib/navigation/Tabs/OTab.vue'
import OSearchInput from '@/lib/forms/SearchInput/OSearchInput.vue'
import { defineComponent, ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { azureIntegrations } from "@/utils/azureIntegrations";
import AzureIntegrationTile from "./AzureIntegrationTile.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
@@ -80,6 +81,7 @@ export default defineComponent({
OIcon,
},
setup() {
const { t } = useI18n();
const searchQuery = ref("");
const activeCategory = ref("all");
@@ -111,6 +113,7 @@ export default defineComponent({
});
return {
t,
searchQuery,
activeCategory,
filteredIntegrations,
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OCard class="h-full flex flex-col transition-all duration-200 rounded-lg hover:-translate-y-0.5 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(255,255,255,0.1)]">
<OCardSection class="pb-2">
<div class="flex items-start justify-between mb-2">
<div class="font-semibold text-base leading-[1.4]" :class="store.state.theme === 'dark' ? 'text-[#e0e0e0]' : 'text-[#1a1a1a]'">
<div class="font-semibold text-base leading-snug text-text-heading">
{{ integration.displayName }}
</div>
<OButton
@@ -33,7 +33,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTooltip content="View Documentation" />
</OButton>
</div>
<div class="text-sm mb-3 leading-normal min-h-[3em]" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="text-sm mb-3 leading-normal min-h-[3em] text-text-secondary">
{{ integration.description }}
</div>
</OCardSection>
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import enUS from '@/locales/languages/en-US.json';
import { createRouter, createWebHistory } from 'vue-router';
import AzureQuickSetup from './AzureQuickSetup.vue';
@@ -56,7 +57,9 @@ const mockStore = createStore({
},
});
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
// Real locale messages: the component renders t() keys, and several
// assertions below check the resulting English text.
const mockI18n = createI18n({ locale: 'en', messages: { en: enUS } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
@@ -23,10 +23,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon name="cloud" size="xl" />
</div>
<div class="flex-1">
<h5 class="text-lg font-bold m-0 mb-2 title" :class="store.state.theme === 'dark' ? 'text-[var(--o2-border)]' : 'text-[#1a1a1a]'">
<h5 class="text-lg font-bold m-0 mb-2 title text-text-heading">
Azure Function Integration
</h5>
<p class="text-sm m-0 mb-4 description" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<p class="text-sm m-0 mb-4 description">
Deploy the OpenObserve Azure Function to collect logs from any
Azure service through diagnostic settings and Event Hub.
</p>
@@ -84,7 +84,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="text-sm details-section">
<h6
class="text-base font-semibold m-0 mb-3 details-title"
:class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'"
>
Supported Azure Services ({{ includedServices.length }})
</h6>
@@ -96,7 +96,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="service.name"
class="w-full col-sm-6"
>
<div class="service-item p-2 rounded border transition-all duration-200 ease-in-out" :class="store.state.theme === 'dark' ? 'border-[#404040] bg-[rgba(255,255,255,0.05)]' : 'border-[var(--o2-border)] bg-[#fafafa]'">
<div class="service-item p-2 rounded border border-border-default bg-surface-subtle hover:bg-surface-subtle-hover transition-all duration-200 ease-in-out">
<div class="flex items-start gap-2">
<OIcon
:name="getCategoryIcon(service.category)"
@@ -105,13 +105,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
color="primary"
/>
<div class="flex-1">
<div class="font-medium text-sm service-name" :class="store.state.theme === 'dark' ? 'text-[var(--o2-border)]' : 'text-[#1a1a1a]'">
<div class="font-medium text-sm service-name text-text-heading">
{{ service.name }}
</div>
<div class="text-xs mt-0.5 service-description" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="text-xs mt-0.5 service-description">
{{ service.description }}
</div>
<div class="text-xs mt-1 font-medium service-category" :class="store.state.theme === 'dark' ? 'text-[#808080]' : 'text-[#999]'">
<div class="text-xs mt-1 font-medium service-category text-text-muted">
{{ formatCategory(service.category) }}
</div>
</div>
@@ -123,26 +123,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<h6
class="text-base font-semibold m-0 mb-3 details-title"
:class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'"
>
How It Works
</h6>
<div class="mb-3">
<div class="font-semibold mb-1 detail-label" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="font-semibold mb-1 detail-label">
Architecture:
</div>
<div class="detail-value" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="detail-value">
Azure Service → Diagnostic Settings → Event Hub → Azure
Function → OpenObserve
</div>
</div>
<div class="mb-3">
<div class="font-semibold mb-1 detail-label" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="font-semibold mb-1 detail-label">
What You Need:
</div>
<ul class="list-disc ml-5 space-y-1 detail-value" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<ul class="list-disc ml-5 space-y-1 detail-value">
<li>Azure subscription with appropriate permissions</li>
<li>Event Hub namespace and hub</li>
<li>Azure Function App</li>
@@ -151,10 +151,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="mb-3">
<div class="font-semibold mb-1 detail-label" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="font-semibold mb-1 detail-label">
Setup Steps:
</div>
<ol class="list-decimal ml-5 space-y-1 detail-value" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<ol class="list-decimal ml-5 space-y-1 detail-value">
<li>Create an Event Hub namespace and hub</li>
<li>Deploy the Azure Function from GitHub</li>
<li>Configure function with OpenObserve credentials</li>
@@ -166,10 +166,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div>
<div class="font-semibold mb-1 detail-label" :class="store.state.theme === 'dark' ? 'text-[#d0d0d0]' : 'text-[#333]'">
<div class="font-semibold mb-1 detail-label">
Cost Considerations:
</div>
<div class="detail-value" :class="store.state.theme === 'dark' ? 'text-[#b0b0b0]' : 'text-[#666]'">
<div class="detail-value">
Charges apply for Event Hub throughput, Azure Function
execution, and OpenObserve storage. Refer to Azure and
OpenObserve pricing for details.
@@ -374,7 +374,3 @@ export default defineComponent({
});
</script>
<style>
.body--light .azure-quick-setup .service-item:hover { background-color: #f5f5f5; }
.body--dark .azure-quick-setup .service-item:hover { background-color: rgba(255, 255, 255, 0.08); }
</style>
@@ -13,521 +13,115 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref } from 'vue';
import config from '../../../aws-exports';
import { getEndPoint, getImageURL, getIngestionURL } from '../../../utils/zincutils';
import { describe, it, expect, vi, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { ref } from "vue";
import GCPConfig from "./GCPConfig.vue";
import gcpCard from "@/components/ingestion/setupCard/content/gcp";
import { getDataSourceCard } from "@/components/ingestion/setupCard/registry";
// Mock dependencies
vi.mock('../../../aws-exports', () => ({
const mockEndpoint = ref({
url: "https://test.openobserve.ai",
host: "test.openobserve.ai",
port: 443,
protocol: "https",
tls: true,
});
vi.mock("@/composables/useIngestion", () => ({
default: vi.fn(() => ({ endpoint: mockEndpoint })),
}));
vi.mock("@/components/ingestion/setupCard/SetupCardRenderer.vue", () => ({
default: {
aws_project_region: 'us-east-1',
aws_cognito_region: 'us-east-1'
}
name: "SetupCardRenderer",
props: ["content", "subs", "logoUrl", "logoUrlDark"],
template: '<div data-test="rich-card-stub" />',
},
}));
vi.mock('../../../utils/zincutils', () => ({
getEndPoint: vi.fn(),
getImageURL: vi.fn(),
getIngestionURL: vi.fn()
}));
// Mock vue-i18n
const mockT = vi.fn((key: string) => key);
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}));
// Mock Vuex store
const mockStore = {
const mockStore = createStore({
state: {
selectedOrganization: {
identifier: 'test-org',
name: 'Test Organization'
}
}
selectedOrganization: { identifier: "test-org", name: "Test Organization" },
userInfo: { email: "test@example.com" },
organizationData: { organizationPasscode: "test-passcode" },
theme: "light",
},
});
const mockI18n = createI18n({ locale: "en", messages: { en: {} } });
const SUBS = {
url: "https://test.openobserve.ai",
org: "test-org",
token: "dGVzdEB0b2tlbg==",
};
describe('GCPConfig.vue Component Logic', () => {
const mockEndpointData = {
url: 'http://localhost:5080',
host: 'localhost',
port: '5080',
protocol: 'http',
tls: 'false'
};
const mockIngestionURL = 'http://localhost:5080';
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getIngestionURL).mockReturnValue(mockIngestionURL);
vi.mocked(getEndPoint).mockReturnValue(mockEndpointData);
vi.mocked(getImageURL).mockReturnValue('mock-image-url');
describe("gcpCard builder", () => {
it("builds the GCP card metadata and step flow", () => {
const card = gcpCard(SUBS);
expect(card.provider.name).toBe("Google Cloud");
expect(card.provider.metaBadges).toEqual(["Logs"]);
expect(card.steps.map((s) => s.id)).toEqual([
"sink",
"subscription",
"verify",
]);
});
it("builds the Pub/Sub push endpoint with org and key", () => {
const sub = gcpCard(SUBS).steps.find((s) => s.id === "subscription")!;
expect(sub.code!.raw).toBe(
`${SUBS.url}/gcp/${SUBS.org}/{stream}/_sub?API-Key=${SUBS.token}`,
);
// The key is masked on screen and only revealed/copied deliberately.
expect(sub.code!.masked).not.toContain(SUBS.token);
});
it("keeps the stream name in lockstep between URL and detection", () => {
const card = gcpCard(SUBS);
// {stream} survives build-time substitution so the renderer fills it from
// the stream-name field, which also drives what detection watches.
expect(card.streamInput?.default).toBe("default");
const sub = card.steps.find((s) => s.id === "subscription")!;
expect(sub.code!.raw).toContain("{stream}");
expect(card.detect.streamType).toBe("logs");
});
it("uses a filter that is valid SQL", () => {
// useStreamDetect interpolates this into `WHERE (<filter>)`, so an empty
// filter would be a syntax error and detection could never succeed.
expect(gcpCard(SUBS).detect.filter.trim()).not.toBe("");
});
it("keeps BOTH doc links the old page linked, as real anchors", () => {
// The pre-migration page listed two clickable links: Pub/Sub Logs and
// Google Workspace. Google Workspace briefly regressed into unclickable
// prose inside a collapsed accordion — it must stay a footer link.
const card = gcpCard(SUBS);
expect(card.docUrl).toContain("send-gcp-logs-to-openobserve");
expect(card.docLinks).toEqual([
{
label: "Google Workspace",
url: "https://short.openobserve.ai/security/google-workspace",
},
]);
});
});
describe("GCPConfig.vue", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
vi.clearAllMocks();
if (wrapper) wrapper.unmount();
});
const createComponentSetup = (customStore = mockStore, customProps = {}) => {
const props = {
currOrgIdentifier: 'test-org',
currUserEmail: 'test@example.com',
...customProps
};
const store = customStore;
const t = mockT;
const endpoint: any = ref({
url: '',
host: '',
port: '',
protocol: '',
tls: ''
});
const ingestionURL = getIngestionURL();
endpoint.value = getEndPoint(ingestionURL);
const content = `URL: ${endpoint.value.url}/gcp/${store.state.selectedOrganization.identifier}/default/_sub?API-Key=[BASIC_PASSCODE]`;
return {
t,
store,
config,
endpoint,
content,
getImageURL,
props
};
};
// Test 1: Component setup initializes correctly
it('should initialize reactive data correctly', () => {
const setup = createComponentSetup();
expect(setup.endpoint.value).toEqual(mockEndpointData);
expect(setup.store).toBe(mockStore);
expect(setup.config).toBeDefined();
expect(setup.content).toContain('URL:');
expect(setup.t).toBe(mockT);
expect(setup.getImageURL).toBeDefined();
it("renders the shared setup card for the gcp slug", () => {
expect(getDataSourceCard("gcp", SUBS)?.provider.name).toBe("Google Cloud");
wrapper = mount(GCPConfig, { global: { plugins: [mockStore, mockI18n] } });
const stub = wrapper.findComponent({ name: "SetupCardRenderer" });
expect(stub.exists()).toBe(true);
expect((stub.props("content") as any).provider.name).toBe("Google Cloud");
});
// Test 2: Component calls getIngestionURL on setup
it('should call getIngestionURL during setup', () => {
createComponentSetup();
expect(getIngestionURL).toHaveBeenCalledTimes(1);
});
// Test 3: Component calls getEndPoint with ingestion URL
it('should call getEndPoint with ingestion URL', () => {
createComponentSetup();
expect(getEndPoint).toHaveBeenCalledWith(mockIngestionURL);
expect(getEndPoint).toHaveBeenCalledTimes(1);
});
// Test 4: Endpoint ref is initialized with empty values initially
it('should initialize endpoint with empty values initially', () => {
const setup = createComponentSetup();
expect(setup.endpoint.value).toEqual(mockEndpointData);
});
// Test 5: Endpoint ref gets populated by getEndPoint result
it('should populate endpoint with getEndPoint result', () => {
const setup = createComponentSetup();
expect(setup.endpoint.value.url).toBe(mockEndpointData.url);
expect(setup.endpoint.value.host).toBe(mockEndpointData.host);
expect(setup.endpoint.value.port).toBe(mockEndpointData.port);
expect(setup.endpoint.value.protocol).toBe(mockEndpointData.protocol);
expect(setup.endpoint.value.tls).toBe(mockEndpointData.tls);
});
// Test 6: Content string is generated correctly with organization identifier
it('should generate content with correct organization identifier', () => {
const setup = createComponentSetup();
expect(setup.content).toContain('/gcp/test-org/default/_sub');
expect(setup.content).toContain(mockEndpointData.url);
});
// Test 7: Content includes proper URL structure
it('should include correct URL structure in content', () => {
const setup = createComponentSetup();
const expectedUrl = `${mockEndpointData.url}/gcp/${mockStore.state.selectedOrganization.identifier}/default/_sub?API-Key=[BASIC_PASSCODE]`;
expect(setup.content).toBe(`URL: ${expectedUrl}`);
});
// Test 8: Content includes API key placeholder
it('should include API key placeholder in content', () => {
const setup = createComponentSetup();
expect(setup.content).toContain('API-Key=[BASIC_PASSCODE]');
});
// Test 9: Component works with different organization identifiers
it('should work with different organization identifiers', () => {
const customStore = {
state: {
selectedOrganization: {
identifier: 'custom-gcp-org',
name: 'Custom GCP Organization'
}
}
};
const setup = createComponentSetup(customStore);
expect(setup.content).toContain('/gcp/custom-gcp-org/default/_sub');
});
// Test 10: Component works with different endpoint URLs
it('should work with different endpoint URLs', () => {
const customEndpoint = {
url: 'https://gcp.example.com',
host: 'gcp.example.com',
port: '443',
protocol: 'https',
tls: 'true'
};
vi.mocked(getEndPoint).mockReturnValue(customEndpoint);
const setup = createComponentSetup();
expect(setup.content).toContain('https://gcp.example.com/gcp/test-org/default/_sub');
expect(setup.endpoint.value).toEqual(customEndpoint);
});
// Test 11: Component exposes store correctly
it('should expose store correctly', () => {
const setup = createComponentSetup();
expect(setup.store).toBe(mockStore);
expect(setup.store.state.selectedOrganization.identifier).toBe('test-org');
});
// Test 12: Component exposes config correctly
it('should expose config correctly', () => {
const setup = createComponentSetup();
expect(setup.config).toBe(config);
});
// Test 13: Component exposes t function correctly
it('should expose t function correctly', () => {
const setup = createComponentSetup();
expect(setup.t).toBe(mockT);
expect(typeof setup.t).toBe('function');
});
// Test 14: Component exposes getImageURL function
it('should expose getImageURL function', () => {
const setup = createComponentSetup();
expect(setup.getImageURL).toBe(getImageURL);
expect(typeof setup.getImageURL).toBe('function');
});
// Test 15: Endpoint ref is reactive
it('should have reactive endpoint ref', () => {
const setup = createComponentSetup();
setup.endpoint.value.url = 'http://new-gcp-url.com';
expect(setup.endpoint.value.url).toBe('http://new-gcp-url.com');
});
// Test 16: Component handles props correctly
it('should handle props correctly', () => {
const customProps = {
currOrgIdentifier: 'prop-org',
currUserEmail: 'prop@example.com'
};
const setup = createComponentSetup(mockStore, customProps);
expect(setup.props.currOrgIdentifier).toBe('prop-org');
expect(setup.props.currUserEmail).toBe('prop@example.com');
});
// Test 17: Component handles empty organization identifier
it('should handle empty organization identifier', () => {
const emptyOrgStore = {
state: {
selectedOrganization: {
identifier: '',
name: ''
}
}
};
const setup = createComponentSetup(emptyOrgStore);
expect(setup.content).toContain('/gcp//default/_sub');
});
// Test 18: Component handles null organization gracefully
it('should handle null organization gracefully', () => {
const nullOrgStore = {
state: {
selectedOrganization: null
}
};
expect(() => createComponentSetup(nullOrgStore)).toThrow();
});
// Test 19: Component handles undefined organization gracefully
it('should handle undefined organization gracefully', () => {
const undefinedOrgStore = {
state: {
selectedOrganization: undefined
}
};
expect(() => createComponentSetup(undefinedOrgStore)).toThrow();
});
// Test 20: Content includes the default subscription endpoint
it('should include default subscription endpoint in content', () => {
const setup = createComponentSetup();
expect(setup.content).toContain('/default/_sub');
});
// Test 21: Component works with HTTPS endpoints
it('should work correctly with HTTPS endpoints', () => {
const httpsEndpoint = {
url: 'https://secure-gcp.example.com',
host: 'secure-gcp.example.com',
port: '443',
protocol: 'https',
tls: 'true'
};
vi.mocked(getEndPoint).mockReturnValue(httpsEndpoint);
const setup = createComponentSetup();
expect(setup.endpoint.value.protocol).toBe('https');
expect(setup.endpoint.value.tls).toBe('true');
expect(setup.content).toContain('https://secure-gcp.example.com/gcp/test-org/default/_sub');
});
// Test 22: Component works with custom ports
it('should work correctly with custom ports', () => {
const customPortEndpoint = {
url: 'http://localhost:8080',
host: 'localhost',
port: '8080',
protocol: 'http',
tls: 'false'
};
vi.mocked(getEndPoint).mockReturnValue(customPortEndpoint);
const setup = createComponentSetup();
expect(setup.endpoint.value.port).toBe('8080');
expect(setup.content).toContain('http://localhost:8080/gcp/test-org/default/_sub');
});
// Test 23: getIngestionURL is called with no parameters
it('should call getIngestionURL with no parameters', () => {
createComponentSetup();
expect(getIngestionURL).toHaveBeenCalledWith();
});
// Test 24: getEndPoint is called exactly once
it('should call getEndPoint exactly once', () => {
createComponentSetup();
expect(getEndPoint).toHaveBeenCalledTimes(1);
});
// Test 25: Component setup returns all expected properties
it('should return all expected properties from setup', () => {
const setup = createComponentSetup();
expect(setup).toHaveProperty('t');
expect(setup).toHaveProperty('store');
expect(setup).toHaveProperty('config');
expect(setup).toHaveProperty('endpoint');
expect(setup).toHaveProperty('content');
expect(setup).toHaveProperty('getImageURL');
});
// Test 26: Content is a string type
it('should have content as string type', () => {
const setup = createComponentSetup();
expect(typeof setup.content).toBe('string');
expect(setup.content.length).toBeGreaterThan(0);
});
// Test 27: Content follows GCP URL pattern
it('should follow GCP URL pattern', () => {
const setup = createComponentSetup();
expect(setup.content).toMatch(/URL:\s*https?:\/\/.+\/gcp\/.+\/default\/_sub\?API-Key=\[BASIC_PASSCODE\]/);
});
// Test 28: Component handles getEndPoint returning null
it('should handle getEndPoint returning null gracefully', () => {
vi.mocked(getEndPoint).mockReturnValue(null as any);
expect(() => createComponentSetup()).toThrow();
});
// Test 29: Component handles getEndPoint returning undefined
it('should handle getEndPoint returning undefined gracefully', () => {
vi.mocked(getEndPoint).mockReturnValue(undefined as any);
expect(() => createComponentSetup()).toThrow();
});
// Test 30: Component handles getIngestionURL returning empty string
it('should handle getIngestionURL returning empty string', () => {
vi.mocked(getIngestionURL).mockReturnValue('');
const setup = createComponentSetup();
expect(getEndPoint).toHaveBeenCalledWith('');
});
// Test 31: Component handles getIngestionURL returning null
it('should handle getIngestionURL returning null', () => {
vi.mocked(getIngestionURL).mockReturnValue(null as any);
const setup = createComponentSetup();
expect(getEndPoint).toHaveBeenCalledWith(null);
});
// Test 32: Component works with different ingestion URLs
it('should work with different ingestion URLs', () => {
const customIngestionURL = 'https://custom.gcp.url:9200';
vi.mocked(getIngestionURL).mockReturnValue(customIngestionURL);
const customEndpoint = {
url: customIngestionURL,
host: 'custom.gcp.url',
port: '9200',
protocol: 'https',
tls: 'true'
};
vi.mocked(getEndPoint).mockReturnValue(customEndpoint);
const setup = createComponentSetup();
expect(getEndPoint).toHaveBeenCalledWith(customIngestionURL);
expect(setup.content).toContain(customIngestionURL);
});
// Test 33: Endpoint ref maintains reactivity
it('should maintain endpoint ref reactivity', () => {
const setup = createComponentSetup();
const originalUrl = setup.endpoint.value.url;
setup.endpoint.value.url = 'http://changed-gcp.com';
expect(setup.endpoint.value.url).not.toBe(originalUrl);
expect(setup.endpoint.value.url).toBe('http://changed-gcp.com');
});
// Test 34: Component exposes correct number of properties
it('should expose exactly 6 properties from setup', () => {
const setup = createComponentSetup();
const keys = Object.keys(setup).filter(key => key !== 'props');
expect(keys).toHaveLength(6);
expect(keys).toContain('t');
expect(keys).toContain('store');
expect(keys).toContain('config');
expect(keys).toContain('endpoint');
expect(keys).toContain('content');
expect(keys).toContain('getImageURL');
});
// Test 35: Component handles special characters in organization identifier
it('should handle special characters in organization identifier', () => {
const specialCharStore = {
state: {
selectedOrganization: {
identifier: 'gcp-org-with-special-chars_123',
name: 'Special GCP Org'
}
}
};
const setup = createComponentSetup(specialCharStore);
expect(setup.content).toContain('/gcp/gcp-org-with-special-chars_123/default/_sub');
});
// Test 36: Endpoint object has all required properties
it('should have endpoint object with all required properties', () => {
const setup = createComponentSetup();
expect(setup.endpoint.value).toHaveProperty('url');
expect(setup.endpoint.value).toHaveProperty('host');
expect(setup.endpoint.value).toHaveProperty('port');
expect(setup.endpoint.value).toHaveProperty('protocol');
expect(setup.endpoint.value).toHaveProperty('tls');
});
// Test 37: Content starts with URL prefix
it('should have content starting with URL prefix', () => {
const setup = createComponentSetup();
expect(setup.content).toMatch(/^URL:\s/);
});
// Test 38: Component handles props with undefined values
it('should handle props with undefined values', () => {
const undefinedProps = {
currOrgIdentifier: undefined,
currUserEmail: undefined
};
const setup = createComponentSetup(mockStore, undefinedProps);
expect(setup.props.currOrgIdentifier).toBeUndefined();
expect(setup.props.currUserEmail).toBeUndefined();
});
// Test 39: Component handles props with empty string values
it('should handle props with empty string values', () => {
const emptyProps = {
currOrgIdentifier: '',
currUserEmail: ''
};
const setup = createComponentSetup(mockStore, emptyProps);
expect(setup.props.currOrgIdentifier).toBe('');
expect(setup.props.currUserEmail).toBe('');
});
// Test 40: t function is properly exposed for i18n
it('should properly expose t function for i18n', () => {
const setup = createComponentSetup();
expect(setup.t).toBe(mockT);
setup.t('test.key');
expect(mockT).toHaveBeenCalledWith('test.key');
});
});
});
@@ -14,81 +14,19 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<!--
Google Cloud setup page. Content lives in setupCard/content/gcp.ts and renders
through the shared DataSourceSetupCard.
-->
<template>
<div class="m-3 mt-1">
<CopyContent :content="content" />
<div class="text-[16px]">
<div class="font-bold pt-6 pb-2">
Check further documentation at:
</div>
<ol class="list-decimal pl-3">
<li class="py-1">
<a
href="https://openobserve.ai/blog/send-gcp-logs-to-openobserve"
class="underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t("ingestion.pubsub") }}
</a>
</li>
<li class="py-1">
<a
href="https://short.openobserve.ai/security/google-workspace"
class="underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t("ingestion.gworkspace") }}
</a>
</li>
</ol>
</div>
</div>
<DataSourceSetupCard slug="gcp" />
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import config from "../../../aws-exports";
import { useStore } from "vuex";
import { getEndPoint, getImageURL, getIngestionURL } from "../../../utils/zincutils";
import CopyContent from "@/components/CopyContent.vue";
import { useI18n } from "vue-i18n";
<script setup lang="ts">
import DataSourceSetupCard from "@/components/ingestion/setupCard/DataSourceSetupCard.vue";
export default defineComponent({
name: "GCPConfig",
props: {
currOrgIdentifier: {
type: String,
},
currUserEmail: {
type: String,
},
},
components: { CopyContent },
setup(props) {
const { t } = useI18n();
const store = useStore();
const endpoint: any = ref({
url: "",
host: "",
port: "",
protocol: "",
tls: "",
});
const ingestionURL = getIngestionURL();
endpoint.value = getEndPoint(ingestionURL);
const content = `URL: ${endpoint.value.url}/gcp/${store.state.selectedOrganization.identifier}/default/_sub?API-Key=[BASIC_PASSCODE]`;
return {
t,
store,
config,
endpoint,
content,
getImageURL,
};
},
});
defineProps<{
currOrgIdentifier?: string;
currUserEmail?: string;
}>();
</script>
@@ -1,294 +1,272 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import { createRouter, createWebHistory } from 'vue-router';
import { nextTick } from 'vue';
import KubernetesConfig from './KubernetesConfig.vue';
import CopyContent from '@/components/CopyContent.vue';
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
vi.mock('../../../aws-exports', () => ({
default: { API_ENDPOINT: 'http://localhost:5080', region: 'us-east-1', isCloud: 'false' },
// Mirrors the Databases leaf specs (see databases/Postgres.spec.ts): the card
// content is asserted on the pure builder, and the component test only proves
// the slug resolves through the shared renderer.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { ref } from "vue";
import KubernetesConfig from "./KubernetesConfig.vue";
import { getDataSourceCard } from "@/components/ingestion/setupCard/registry";
// `isCloud` gates the in-cluster variants, so it must be mutable per-test.
const mockConfig = { isCloud: "false" };
vi.mock("@/aws-exports", () => ({
get default() {
return mockConfig;
},
}));
vi.mock('../../../utils/zincutils', () => ({
getImageURL: vi.fn((path) => `mock-image-url-${path}`),
getEndPoint: vi.fn(() => ({
url: 'http://localhost:5080',
host: 'localhost',
port: '5080',
protocol: 'http',
tls: false,
})),
getIngestionURL: vi.fn(() => 'http://localhost:5080'),
b64EncodeStandard: vi.fn((str) => btoa(str)),
const mockEndpoint = ref({
url: "https://test.openobserve.ai",
host: "test.openobserve.ai",
port: 443,
protocol: "https",
tls: true,
});
vi.mock("@/composables/useIngestion", () => ({
default: vi.fn(() => ({ endpoint: mockEndpoint })),
}));
vi.mock('@/components/CopyContent.vue', () => ({
vi.mock("@/components/ingestion/setupCard/SetupCardRenderer.vue", () => ({
default: {
name: 'CopyContent',
template: '<div data-test="copy-content">{{ content }}</div>',
props: ['content'],
name: "SetupCardRenderer",
props: ["content", "subs", "logoUrl", "logoUrlDark"],
template: '<div data-test="rich-card-stub" />',
},
}));
const mockStore = createStore({
state: {
selectedOrganization: { identifier: 'test-org', name: 'Test Organization' },
userInfo: { email: 'test@example.com' },
organizationData: { organizationPasscode: 'test-passcode' },
theme: 'light',
selectedOrganization: { identifier: "test-org", name: "Test Organization" },
userInfo: { email: "test@example.com" },
organizationData: { organizationPasscode: "test-passcode" },
theme: "light",
},
});
const mockI18n = createI18n({
locale: 'en',
messages: {
en: {
ingestion: {
external: 'External Endpoint',
internal: 'Internal Endpoint',
internalLabel: 'Use this if OpenObserve is in the same cluster',
},
},
},
});
const mockI18n = createI18n({ locale: "en", messages: { en: {} } });
const mockRouter = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
});
const SUBS = {
url: "https://test.openobserve.ai",
org: "test-org",
token: "dGVzdEB0b2tlbg==",
};
// Imported lazily inside tests so the mocked aws-exports value is picked up.
const buildCard = async () =>
(await import("@/components/ingestion/setupCard/content/kubernetes")).default(
SUBS,
);
describe('KubernetesConfig.vue', () => {
let wrapper: VueWrapper;
describe("kubernetesCard builder", () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig.isCloud = "false";
});
it("builds the Kubernetes card metadata and step flow", async () => {
const card = await buildCard();
expect(card.provider.name).toBe("Kubernetes");
expect(card.provider.metaBadges).toEqual([
"Logs",
"Metrics",
"Events",
"Traces",
]);
expect(card.steps.map((s) => s.id)).toEqual([
"install",
"instrument",
"verify",
]);
expect(card.detect).toMatchObject({
streamType: "logs",
streamName: "default",
});
});
it("takes the cluster name as a live input feeding the install command", async () => {
const install = (await buildCard()).steps.find((s) => s.id === "install")!;
expect(install.required).toBe(true);
expect(install.inputs?.map((i) => i.id)).toEqual(["cluster"]);
expect(install.inputs![0].default).toBe("cluster1");
// The {cluster} placeholder must survive build-time substitution so the
// renderer can fill it as the user types.
for (const v of install.variants!) {
expect(v.code.raw).toContain("{cluster}");
}
});
it("substitutes the org and token, and masks the token", async () => {
const install = (await buildCard()).steps.find((s) => s.id === "install")!;
const external = install.variants!.find((v) => v.id === "external")!;
expect(external.code.raw).toContain(`--org-id=${SUBS.org}`);
expect(external.code.raw).toContain(`--access-key=${SUBS.token}`);
expect(external.code.raw).toContain(`--o2-url=${SUBS.url}`);
expect(external.code.masked).not.toContain(SUBS.token);
});
it("marks the quick install as the recommended path", async () => {
const install = (await buildCard()).steps[0];
expect(install.title).toBe("Quick Install (Recommended)");
expect(install.required).toBe(true);
});
it("points from the install step to the advanced section", async () => {
// The accordion sits below the verify step, so without this pointer a user
// who wants Helm has to scroll past two steps that don't apply to them.
const card = await buildCard();
const install = card.steps[0];
const label = card.extras!.advanced!.label;
for (const v of install.variants!) {
// A jump link, not prose — it opens the accordion and scrolls to it.
expect(v.note).toContain(`[${label}](#advanced)`);
}
expect(label).toBe("Advanced Installation (Manual Steps)");
});
it("keeps the quick install to the two endpoint choices", async () => {
// The one-line installer is the recommended path — Helm must NOT dilute
// this toggle (it lives in the collapsed advanced section instead).
const install = (await buildCard()).steps[0];
expect(install.variants?.map((v) => v.id)).toEqual(["external", "internal"]);
for (const v of install.variants!) {
expect(v.code.raw).toContain("install.sh");
expect(v.code.raw).not.toContain("helm");
}
});
it("drops the endpoint toggle entirely on cloud", async () => {
// Cloud has no in-cluster router, so there is nothing to choose between.
mockConfig.isCloud = "true";
vi.resetModules();
const install = (await buildCard()).steps[0];
expect(install.variants).toBeUndefined();
expect(install.code?.raw).toContain("--o2-url=");
// With no variants to carry it, the installer note (and the jump link to
// the advanced section) moves onto the step itself.
expect(install.note).toContain(
"[Advanced Installation (Manual Steps)](#advanced)",
);
});
it("points the internal variant at the cluster-local router", async () => {
const internal = (await buildCard()).steps[0].variants!.find(
(v) => v.id === "internal",
)!;
expect(internal.code.raw).toContain(
"--internal-endpoint=http://o2-openobserve-router.openobserve.svc.cluster.local:5080",
);
expect(internal.code.raw).not.toContain("--o2-url");
});
it("keeps the manual helm sequence as one collapsed advanced block", async () => {
const advanced = (await buildCard()).extras!.advanced!;
expect(advanced.label).toContain("Advanced Installation");
const code = advanced.code.raw;
// Previously six separate copy boxes plus a "wait 2 minutes" instruction.
expect(code).toContain("cert-manager.yaml");
expect(code).toContain("kubectl wait --for=condition=Available");
expect(code).toContain("prometheus-operator");
expect(code).toContain("opentelemetry-operator.yaml");
expect(code).toContain("helm repo add openobserve");
expect(code).toContain("upgrade --install o2c");
expect(code).toContain(`endpoint=${SUBS.url}/api/${SUBS.org}`);
// Shares the step's live {cluster} input and masks the token.
expect(code).toContain("{cluster}");
expect(advanced.code.masked).not.toContain(SUBS.token);
});
it("exposes auto-instrumentation as an optional per-language step", async () => {
const step = (await buildCard()).steps.find((s) => s.id === "instrument")!;
// Not required — the chip renders "Optional" off the absent `required`.
expect(step.required).toBeUndefined();
expect(step.variants?.map((v) => v.id)).toEqual([
"java",
"dotnet",
"nodejs",
"python",
"go",
]);
expect(step.variants!.every((v) => !!v.icon)).toBe(true);
const java = step.variants!.find((v) => v.id === "java")!;
expect(java.code.raw).toContain(
'instrumentation.opentelemetry.io/inject-java="openobserve-collector/openobserve-java"',
);
// Injection only happens at pod creation, so a restart is part of the step.
expect(java.code.raw).toContain("kubectl rollout restart");
// Go's eBPF instrumentation additionally needs the in-container binary path.
const go = step.variants!.find((v) => v.id === "go")!;
expect(go.code.raw).toContain(
"instrumentation.opentelemetry.io/otel-go-auto-target-exe",
);
});
it("anchors detection on the verify step with a valid SQL filter", async () => {
const card = await buildCard();
const verify = card.steps.find((s) => s.id === "verify")!;
expect(verify.completeOn).toBe("detect");
expect(verify.detectionAnchor).toBe(true);
// useStreamDetect interpolates this into `WHERE (<filter>)`, so an empty
// filter would produce invalid SQL and never connect.
expect(card.detect.filter.trim()).not.toBe("");
});
it("keeps the example and operator references as real anchors", async () => {
// Both were <a> tags on the pre-migration page; they must not degrade into
// unclickable text inside the troubleshooting accordion.
const card = await buildCard();
const urls = (card.docLinks ?? []).map((l) => l.url);
expect(urls).toContain("https://github.com/openobserve/hotcommerce");
expect(urls).toContain(
"https://github.com/open-telemetry/opentelemetry-operator",
);
});
it("carries troubleshooting guidance without markdown link syntax", async () => {
const ts = (await buildCard()).extras!.troubleshooting!;
expect(ts.length).toBeGreaterThan(0);
// inlineMd only renders **bold** and `code`; links would show as raw text.
for (const row of ts) {
expect(row.a).not.toMatch(/\[[^\]]+\]\([^)]+\)/);
}
});
});
describe("KubernetesConfig.vue", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
if (wrapper) wrapper.unmount();
});
const createWrapper = (props = {}) => {
const defaultProps = { currOrgIdentifier: 'test-org', currUserEmail: 'test@example.com' };
return mount(KubernetesConfig, {
props: { ...defaultProps, ...props },
global: {
plugins: [mockI18n, mockRouter],
provide: { store: mockStore },
components: { CopyContent },
},
});
};
describe('Component Rendering', () => {
it('should mount without errors', () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it('should unmount without errors', () => {
wrapper = createWrapper();
expect(() => wrapper.unmount()).not.toThrow();
});
it('should render Quick Install section', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Quick Install');
});
it('should render Cluster Name input', () => {
wrapper = createWrapper();
const input = wrapper.find('[data-test="kubernetes-cluster-name-input"]');
expect(input.exists()).toBe(true);
});
it('should render advanced install expansion toggle', () => {
wrapper = createWrapper();
const toggle = wrapper.find('[data-test="kubernetes-advanced-install-toggle"]');
expect(toggle.exists()).toBe(true);
});
it('should render CopyContent components', () => {
wrapper = createWrapper();
const copyContents = wrapper.findAllComponents(CopyContent);
expect(copyContents.length).toBeGreaterThan(0);
});
it('should render kubernetes collector info', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Kubernetes cluster');
});
});
describe('Props Validation', () => {
it('should accept currOrgIdentifier prop', () => {
wrapper = createWrapper({ currOrgIdentifier: 'my-org' });
expect(wrapper.exists()).toBe(true);
});
it('should accept currUserEmail prop', () => {
wrapper = createWrapper({ currUserEmail: 'user@test.com' });
expect(wrapper.exists()).toBe(true);
});
});
describe('Reactive Data', () => {
it('should have clusterName with default value cluster1', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.clusterName).toBe('cluster1');
});
it('should have installType with default value external', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.installType).toBe('external');
});
it('should have showAdvancedInstall default false', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.showAdvancedInstall).toBe(false);
});
it('should update clusterName reactively', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.clusterName = 'my-cluster';
await nextTick();
expect(vm.clusterName).toBe('my-cluster');
});
});
describe('quickInstallCmd Computed', () => {
it('should generate quickInstallCmd string', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(typeof vm.quickInstallCmd).toBe('string');
});
it('should include curl in quickInstallCmd', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.quickInstallCmd).toContain('curl');
});
it('should include install.sh in quickInstallCmd', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.quickInstallCmd).toContain('install.sh');
});
it('should include cluster name in quickInstallCmd', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.quickInstallCmd).toContain('cluster1');
});
it('should include org identifier in quickInstallCmd', () => {
wrapper = createWrapper({ currOrgIdentifier: 'my-org' });
const vm = wrapper.vm as any;
expect(vm.quickInstallCmd).toContain('my-org');
});
it('should include endpoint URL in quickInstallCmd', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.quickInstallCmd).toContain('http://localhost:5080');
});
it('should include internal-endpoint for internal installType', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.installType = 'internal';
await nextTick();
expect(vm.quickInstallCmd).toContain('--internal-endpoint');
});
it('should not include internal-endpoint for external installType', async () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.installType = 'external';
await nextTick();
expect(vm.quickInstallCmd).not.toContain('--internal-endpoint');
});
});
describe('helmUpdateCmd Computed', () => {
it('should generate helmUpdateCmd string', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(typeof vm.helmUpdateCmd).toBe('string');
});
it('should include helm repo add command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.helmUpdateCmd).toContain('helm repo add openobserve');
});
it('should include helm repo update command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.helmUpdateCmd).toContain('helm repo update');
});
});
describe('crdCommand Computed', () => {
it('should generate crdCommand string', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(typeof vm.crdCommand).toBe('string');
});
it('should include kubectl create commands', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.crdCommand).toContain('kubectl create');
});
it('should include prometheus-operator CRDs', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.crdCommand).toContain('prometheus-operator');
});
});
describe('collectorCmd Computed', () => {
it('should generate collectorCmd string', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(typeof vm.collectorCmd).toBe('string');
});
it('should include helm upgrade command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.collectorCmd).toContain('helm');
expect(vm.collectorCmd).toContain('upgrade --install');
});
it('should include org identifier in collectorCmd', () => {
wrapper = createWrapper({ currOrgIdentifier: 'test-org' });
const vm = wrapper.vm as any;
expect(vm.collectorCmd).toContain('test-org');
});
});
describe('Utilities', () => {
it('should call getEndPoint on setup', async () => {
const zincutils = await import('../../../utils/zincutils');
wrapper = createWrapper();
expect(zincutils.getEndPoint).toHaveBeenCalled();
});
it('should call getIngestionURL on setup', async () => {
const zincutils = await import('../../../utils/zincutils');
wrapper = createWrapper();
expect(zincutils.getIngestionURL).toHaveBeenCalled();
it("renders the shared setup card for the kubernetes slug", () => {
expect(getDataSourceCard("kubernetes", SUBS)?.provider.name).toBe(
"Kubernetes",
);
wrapper = mount(KubernetesConfig, {
global: { plugins: [mockStore, mockI18n] },
});
const stub = wrapper.findComponent({ name: "SetupCardRenderer" });
expect(stub.exists()).toBe(true);
expect((stub.props("content") as any).provider.name).toBe("Kubernetes");
});
});
@@ -1,328 +1,33 @@
<!-- eslint-disable no-useless-escape -->
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<!--
Kubernetes setup page. Content (steps, install variants, auto-instrumentation,
troubleshooting) lives in setupCard/content/kubernetes.ts and renders through
the shared DataSourceSetupCard — the same card every Databases page uses.
-->
<template>
<div class="p-1 kubernetes-config-section pb-lg">
<!-- Quick Install Section -->
<div class="mb-6 p-4 rounded-lg" :class="quickInstallBgClass">
<div class="flex items-start gap-3">
<OIcon name="rocket-launch" size="xl" class="text-[var(--q-primary)]" />
<div class="flex-1">
<h6 class="text-xl! font-bold m-0 mb-2!">Quick Install (Recommended)</h6>
<p class="text-sm mt-0 mb-6!" :class="descriptionClass">
Install OpenObserve collector with a single command. Just set your cluster name and run.
</p>
<div class="mb-3">
<OInput
v-model="clusterName"
label="Cluster Name"
placeholder="e.g., production, staging, dev"
class="max-w-md"
data-test="kubernetes-cluster-name-input"
>
<template #icon-left>
<OIcon name="dns" size="sm" />
</template>
</OInput>
</div>
<div v-if="config.isCloud != 'true'" class="mb-3">
<OTabs v-model="installType" dense>
<OTab name="external" label="External Endpoint" />
<OTab name="internal" label="Internal Endpoint">
<OTooltip content="Use this if OpenObserve is in the same cluster" side="top" />
</OTab>
</OTabs>
</div>
<ContentCopy class="mt-3" :content="quickInstallCmd" :key="`${clusterName}-${installType}`" />
<div class="mt-2 text-xs" :class="hintClass">
<OIcon name="info" size="sm" class="mr-1" />
This installs cert-manager, OpenTelemetry operator, and OpenObserve collector automatically
</div>
</div>
</div>
</div>
<OSeparator class="mb-6" />
<!-- Advanced/Manual Install Section -->
<OCollapsible
v-model="showAdvancedInstall"
label="Advanced Installation (Manual Steps)"
caption="For custom configurations or step-by-step installation"
data-test="kubernetes-advanced-install-toggle"
>
<div class="mt-4">
<div class="text-base font-medium pl-1 mt-3">Install cert-manager</div>
<ContentCopy
class="mt-2"
content="kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.0/cert-manager.yaml"
/>
<div class="text-base font-medium pl-1 mt-3">
Wait for 2 minutes after installing cert-manger for the webhook to be
ready.
</div>
<div class="text-base font-medium pl-1 mt-3">Update helm repo</div>
<ContentCopy class="mt-2" :content="helmUpdateCmd" />
<div class="text-base font-medium pl-1 mt-3">
Install Prometheus operator CRDs(Required by Opentelemetry operator)
</div>
<ContentCopy class="mt-2" :content="crdCommand" />
<div class="text-base font-medium pl-1 q:mt-md">
Install OpenTelemetry operator
</div>
<ContentCopy
class="mt-2"
content="kubectl apply -f https://raw.githubusercontent.com/openobserve/openobserve-helm-chart/refs/heads/main/opentelemetry-operator.yaml"
/>
<div class="text-base font-medium pl-1 mt-3">Create namespace</div>
<ContentCopy
class="mt-2"
content="kubectl create ns openobserve-collector"
/>
<div class="text-base font-medium pl-1 mt-3">
Install OpenObserve collector
</div>
<div v-if="config.isCloud == 'true'">
<ContentCopy class="mt-2" :content="collectorCmd" />
</div>
<div v-else>
<OTabs v-model="tab" horizontalalign="left">
<OTab
data-test="kubernetes-default-tab"
name="external"
:label="t('ingestion.external')"
/>
<OTab
data-test="kubernetes-this-tab"
name="internal"
:label="t('ingestion.internal')"
>
<OTooltip :content="t('ingestion.internalLabel')" side="top" />
</OTab>
</OTabs>
<OSeparator />
<OTabPanels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
<OTabPanel name="internal" data-test="kubernetes-tab-panels-this">
<ContentCopy class="mt-2" :content="collectorCmdThisCluster" />
<pre>
Format of the URL is: http://&lt;helm-release-name&gt;-openobserve-router.&lt;namespace&gt;.svc.cluster.local
Make changes accordingly to the above URL.
</pre>
</OTabPanel>
<OTabPanel name="external" data-test="kubernetes-tab-panels-default">
<ContentCopy class="mt-2" :content="collectorCmd" />
</OTabPanel>
</OTabPanels>
</div>
</div>
</OCollapsible>
<br />
<hr />
<div class="text-base font-medium pl-1 mt-3 mb-4">
Once you have installed the OpenObserve collector, it will:
<ul class="list-disc ml-5">
<li>Collect metrics from your Kubernetes cluster</li>
<li>Collect events from your Kubernetes cluster</li>
<li>Collect logs from your Kubernetes cluster</li>
<li>
Allow you to capture traces without instrumenting your applications
that are written in following languages using OpenTelemetry
auto-instrumentation. It can be done by setting the following to the
pod/namespace annotations:
<ul class="list-disc ml-5">
<li>
<b>Java:</b> instrumentation.opentelemetry.io/inject-java:
"openobserve-collector/openobserve-java"
</li>
<li>
<b>DotNet:</b> instrumentation.opentelemetry.io/inject-dotnet:
"openobserve-collector/openobserve-dotnet"
</li>
<li>
<b>NodeJS:</b> instrumentation.opentelemetry.io/inject-nodejs:
"openobserve-collector/openobserve-nodejs"
</li>
<li>
<b>Python:</b> instrumentation.opentelemetry.io/inject-python:
"openobserve-collector/openobserve-python"
</li>
<li>
<b>Go (Uses eBPF):</b>
<ul class="list-disc ml-5">
<li>
instrumentation.opentelemetry.io/inject-go:
"openobserve-collector/openobserve-go"
</li>
<li>
instrumentation.opentelemetry.io/otel-go-auto-target-exe:
"/path/to/container/executable"
</li>
</ul>
</li>
</ul>
</li>
</ul>
You can refer and install
<a
href="https://github.com/openobserve/hotcommerce"
class="hover:underline text-primary"
>HOT commerce</a
>
app as an example to understand how this works in practice. Refer to
<a
href="https://github.com/open-telemetry/opentelemetry-operator"
class="hover:underline text-primary"
>OpenTelemetry operator</a
>
for further documentation.
</div>
</div>
<DataSourceSetupCard slug="kubernetes" />
</template>
<script setup lang="ts">
import OTabs from '@/lib/navigation/Tabs/OTabs.vue'
import OTab from '@/lib/navigation/Tabs/OTab.vue'
import OTabPanels from '@/lib/navigation/Tabs/OTabPanels.vue'
import OTabPanel from '@/lib/navigation/Tabs/OTabPanel.vue'
import OInput from '@/lib/forms/Input/OInput.vue'
import OTooltip from '@/lib/overlay/Tooltip/OTooltip.vue'
import { computed, ref, type Ref } from "vue";
import type { Endpoint } from "@/ts/interfaces";
import ContentCopy from "@/components/CopyContent.vue";
import { useStore } from "vuex";
import { b64EncodeStandard, getEndPoint, getIngestionURL } from "../../../utils/zincutils";
import config from "@/aws-exports";
import { useI18n } from "vue-i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import DataSourceSetupCard from "@/components/ingestion/setupCard/DataSourceSetupCard.vue";
const store = useStore();
const props = defineProps({
currOrgIdentifier: {
type: String,
},
currUserEmail: {
type: String,
},
});
const endpoint: any = ref({
url: "",
host: "",
port: "",
protocol: "",
tls: "",
});
const ingestionURL = getIngestionURL();
const tab = ref("external");
const installType = ref("external");
const clusterName = ref("cluster1");
const showAdvancedInstall = ref(false);
const { t } = useI18n();
endpoint.value = getEndPoint(ingestionURL);
const accessKey = computed(() => {
return b64EncodeStandard(
`${props.currUserEmail}:${store.state.organizationData.organizationPasscode}`,
);
});
// Computed class for styling based on theme
const quickInstallBgClass = computed(() => {
return store.state.theme === 'dark'
? 'bg-gray-800 border border-gray-700'
: 'bg-blue-50 border border-blue-200';
});
const descriptionClass = computed(() => {
return store.state.theme === 'dark' ? 'text-gray-300' : 'text-gray-700';
});
const hintClass = computed(() => {
return store.state.theme === 'dark' ? 'text-gray-400' : 'text-gray-600';
});
// Quick install command
const quickInstallCmd = computed(() => {
const baseCmd = 'curl -sSL https://raw.githubusercontent.com/openobserve/o2-datasource/main/k8s/install.sh | bash -s --';
if (config.isCloud === 'true') {
// Cloud version - external endpoint only
return `${baseCmd} \\
--cluster-name=${clusterName.value} \\
--o2-url=${endpoint.value.url} \\
--org-id=${props.currOrgIdentifier} \\
--access-key=${accessKey.value}`;
} else {
// Self-hosted version - support both internal and external
if (installType.value === 'internal') {
return `${baseCmd} \\
--cluster-name=${clusterName.value} \\
--org-id=${props.currOrgIdentifier} \\
--access-key=${accessKey.value} \\
--internal-endpoint=http://o2-openobserve-router.openobserve.svc.cluster.local:5080`;
} else {
return `${baseCmd} \\
--cluster-name=${clusterName.value} \\
--o2-url=${endpoint.value.url} \\
--org-id=${props.currOrgIdentifier} \\
--access-key=${accessKey.value}`;
}
}
});
const collectorCmd = computed(() => {
return `helm --namespace openobserve-collector \\
upgrade --install o2c openobserve/openobserve-collector \\
--set k8sCluster=${clusterName.value} \\
--set exporters.'otlphttp/openobserve'.endpoint=${endpoint.value.url}/api/${props.currOrgIdentifier} \\
--set exporters.'otlphttp/openobserve'.headers.Authorization='Basic ${accessKey.value}' \\
--set exporters.'otlphttp/openobserve_k8s_events'.endpoint=${endpoint.value.url}/api/${props.currOrgIdentifier} \\
--set exporters.'otlphttp/openobserve_k8s_events'.headers.Authorization='Basic ${accessKey.value}'`;
});
const collectorCmdThisCluster = computed(() => {
return `helm --namespace openobserve-collector \\
upgrade --install o2c openobserve/openobserve-collector \\
--set k8sCluster=${clusterName.value} \\
--set exporters.'otlphttp/openobserve'.endpoint=http://o2-openobserve-router.openobserve.svc.cluster.local:5080/api/${props.currOrgIdentifier} \\
--set exporters.'otlphttp/openobserve'.headers.Authorization='Basic ${accessKey.value}' \\
--set exporters.'otlphttp/openobserve_k8s_events'.endpoint=http://o2-openobserve-router.openobserve.svc.cluster.local:5080/api/${props.currOrgIdentifier} \\
--set exporters.'otlphttp/openobserve_k8s_events'.headers.Authorization='Basic ${accessKey.value}'`;
});
const crdCommand = computed(() => {
// Club kubectl create crd commands from template
return `kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/main/example/prometheus-operator-crd/monitoring.coreos.com_servicemonitors.yaml
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/main/example/prometheus-operator-crd/monitoring.coreos.com_podmonitors.yaml
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/refs/heads/main/example/prometheus-operator-crd/monitoring.coreos.com_scrapeconfigs.yaml
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/refs/heads/main/example/prometheus-operator-crd/monitoring.coreos.com_probes.yaml`;
});
const helmUpdateCmd = computed(() => {
return `helm repo add openobserve https://charts.openobserve.ai
helm repo update`;
});
defineProps<{
currOrgIdentifier?: string;
currUserEmail?: string;
}>();
</script>
@@ -1,174 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import { createRouter, createWebHistory } from 'vue-router';
import LinuxConfig from './LinuxConfig.vue';
import CopyContent from '@/components/CopyContent.vue';
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
vi.mock('../../../aws-exports', () => ({
default: { API_ENDPOINT: 'http://localhost:5080', region: 'us-east-1' },
// Mirrors the Databases leaf specs: content asserted on the pure builder, the
// component test only proves the slug resolves through the shared renderer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { ref } from "vue";
import LinuxConfig from "./LinuxConfig.vue";
import linuxCard from "@/components/ingestion/setupCard/content/linux";
import { getDataSourceCard } from "@/components/ingestion/setupCard/registry";
const mockEndpoint = ref({
url: "https://test.openobserve.ai",
host: "test.openobserve.ai",
port: 443,
protocol: "https",
tls: true,
});
vi.mock("@/composables/useIngestion", () => ({
default: vi.fn(() => ({ endpoint: mockEndpoint })),
}));
vi.mock('../../../utils/zincutils', () => ({
getImageURL: vi.fn((path) => `mock-image-url-${path}`),
getEndPoint: vi.fn(() => ({ url: 'http://localhost:5080', host: 'localhost', port: '5080', protocol: 'http', tls: false })),
getIngestionURL: vi.fn(() => 'http://localhost:5080'),
b64EncodeStandard: vi.fn((str) => btoa(str)),
}));
vi.mock('@/components/CopyContent.vue', () => ({
vi.mock("@/components/ingestion/setupCard/SetupCardRenderer.vue", () => ({
default: {
name: 'CopyContent',
template: '<div data-test="copy-content">{{ content }}</div>',
props: ['content'],
name: "SetupCardRenderer",
props: ["content", "subs", "logoUrl", "logoUrlDark"],
template: '<div data-test="rich-card-stub" />',
},
}));
const mockStore = createStore({
state: {
selectedOrganization: { identifier: 'test-org', name: 'Test Organization' },
userInfo: { email: 'test@example.com' },
organizationData: { organizationPasscode: 'test-passcode' },
selectedOrganization: { identifier: "test-org", name: "Test Organization" },
userInfo: { email: "test@example.com" },
organizationData: { organizationPasscode: "test-passcode" },
theme: "light",
},
});
const mockI18n = createI18n({ locale: "en", messages: { en: {} } });
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
const mockRouter = createRouter({ history: createWebHistory(), routes: [{ path: '/', component: { template: '<div>Home</div>' } }] });
const SUBS = {
url: "https://test.openobserve.ai",
org: "test-org",
token: "dGVzdEB0b2tlbg==",
};
describe('LinuxConfig.vue', () => {
let wrapper: VueWrapper;
beforeEach(() => {
vi.clearAllMocks();
describe("linuxCard builder", () => {
it("builds the Linux card metadata and step flow", () => {
const card = linuxCard(SUBS);
expect(card.provider.name).toBe("Linux");
expect(card.provider.metaBadges).toEqual(["Logs", "Metrics"]);
expect(card.steps.map((s) => s.id)).toEqual(["install", "verify"]);
// Host metrics fan out per metric, so detection is existence-based.
expect(card.detect).toMatchObject({
streamType: "metrics",
match: "keyword",
});
});
it("offers generic and EC2 environments as one toggle", () => {
// Replaces two hand-built clickable <div> cards plus a conditional callout.
const install = linuxCard(SUBS).steps[0];
expect(install.required).toBe(true);
expect(install.variants?.map((v) => v.id)).toEqual(["generic", "ec2"]);
expect(install.variants!.every((v) => !!v.icon)).toBe(true);
});
it("points each variant at its matching install script", () => {
const [generic, ec2] = linuxCard(SUBS).steps[0].variants!;
expect(generic.code.raw).toContain("/linux/install.sh");
expect(generic.code.raw).not.toContain("/ec2/");
expect(ec2.code.raw).toContain("/linux/ec2/install.sh");
});
it("carries the IAM prerequisite only on the EC2 variant", () => {
// Previously an amber callout rendered above the command for everyone.
const [generic, ec2] = linuxCard(SUBS).steps[0].variants!;
expect(ec2.note).toContain("ec2:DescribeTags");
expect(generic.note).not.toContain("ec2:DescribeTags");
});
it("substitutes url/org and masks the ingestion token", () => {
const generic = linuxCard(SUBS).steps[0].variants![0];
expect(generic.code.raw).toContain(`${SUBS.url}/api/${SUBS.org}/`);
expect(generic.code.raw).toContain(SUBS.token);
// The old page rendered a literal [BASIC_PASSCODE] placeholder instead.
expect(generic.code.raw).not.toContain("[BASIC_PASSCODE]");
expect(generic.code.masked).not.toContain(SUBS.token);
});
});
describe("LinuxConfig.vue", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
if (wrapper) wrapper.unmount();
});
const createWrapper = (props = {}) => {
const defaultProps = { currOrgIdentifier: 'test-org', currUserEmail: 'test@example.com' };
return mount(LinuxConfig, {
props: { ...defaultProps, ...props },
global: {
plugins: [mockI18n, mockRouter],
provide: { store: mockStore },
components: { CopyContent },
},
});
};
describe('Component Rendering', () => {
it('should mount without errors', () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it('should unmount without errors', () => {
wrapper = createWrapper();
expect(() => wrapper.unmount()).not.toThrow();
});
it('should render CopyContent component', () => {
wrapper = createWrapper();
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.exists()).toBe(true);
});
it('should render installation description', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Once installed');
});
it('should render system logs collection info', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Collect system logs');
});
it('should render host metrics collection info', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Collect host metrics');
});
});
describe('Props Validation', () => {
it('should accept currOrgIdentifier prop', () => {
wrapper = createWrapper({ currOrgIdentifier: 'my-org' });
expect(wrapper.exists()).toBe(true);
});
it('should accept currUserEmail prop', () => {
wrapper = createWrapper({ currUserEmail: 'user@test.com' });
expect(wrapper.exists()).toBe(true);
});
});
describe('activeCommand Computed', () => {
it('should generate a command string', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(typeof vm.activeCommand).toBe('string');
});
it('should include install.sh in the command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('install.sh');
});
it('should include the endpoint URL in the command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('http://localhost:5080');
});
it('should include the org identifier in the command', () => {
wrapper = createWrapper({ currOrgIdentifier: 'my-org' });
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('my-org');
});
it('should include BASIC_PASSCODE placeholder', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('[BASIC_PASSCODE]');
});
it('should include curl command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('curl');
});
});
describe('CopyContent Integration', () => {
it('should pass getCommand to CopyContent', () => {
wrapper = createWrapper();
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.props('content')).toBeDefined();
expect(typeof copyContent.props('content')).toBe('string');
});
it('should pass command with org identifier to CopyContent', () => {
wrapper = createWrapper({ currOrgIdentifier: 'test-org' });
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.props('content')).toContain('test-org');
});
});
describe('getImageURL and Utilities', () => {
it('should call getEndPoint on setup', async () => {
const zincutils = await import('../../../utils/zincutils');
wrapper = createWrapper();
expect(zincutils.getEndPoint).toHaveBeenCalled();
});
it('should call getIngestionURL on setup', async () => {
const zincutils = await import('../../../utils/zincutils');
wrapper = createWrapper();
expect(zincutils.getIngestionURL).toHaveBeenCalled();
});
it("renders the shared setup card for the linux slug", () => {
expect(getDataSourceCard("linux", SUBS)?.provider.name).toBe("Linux");
wrapper = mount(LinuxConfig, { global: { plugins: [mockStore, mockI18n] } });
const stub = wrapper.findComponent({ name: "SetupCardRenderer" });
expect(stub.exists()).toBe(true);
expect((stub.props("content") as any).provider.name).toBe("Linux");
});
});
@@ -1,112 +1,36 @@
<template>
<div class="p-3">
<!-- Environment selection cards -->
<div class="grid grid-cols-2 gap-3 mb-5">
<div
v-for="opt in options"
:key="opt.value"
class="rounded-lg border p-4 cursor-pointer transition-all flex gap-3 items-start"
:class="selected === opt.value ? selectedCardClass : unselectedCardClass"
@click="selected = opt.value"
>
<OIcon :name="opt.icon" size="lg" :class="selected === opt.value ? 'text-[var(--q-primary)]' : 'text-gray-400'" />
<div>
<div class="font-semibold text-sm">{{ opt.label }}</div>
<div class="text-xs mt-0.5" :class="store.state.theme === 'dark' ? 'text-gray-400' : 'text-gray-500'">{{ opt.description }}</div>
</div>
</div>
</div>
<!-- Copyright 2026 OpenObserve Inc.
<!-- EC2 IAM prerequisite note -->
<div
v-if="selected === 'ec2'"
class="flex gap-2 items-start rounded-lg p-3 mb-4 text-sm"
:class="store.state.theme === 'dark' ? 'bg-amber-950/40 border border-amber-800/50' : 'bg-amber-50 border border-amber-200'"
>
<OIcon name="info" size="sm" class="text-amber-500 shrink-0 mt-0.5" />
<div :class="store.state.theme === 'dark' ? 'text-amber-200' : 'text-amber-900'">
<span class="font-semibold">IAM role required.</span>
Attach an IAM role to your EC2 instance with
<code class="font-mono text-xs">ec2:DescribeTags</code> and
<code class="font-mono text-xs">ec2:DescribeInstances</code>
permissions. This allows the collector to read the instance Name tag and use it as the host identifier.
</div>
</div>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
<!-- Install command -->
<ContentCopy :content="activeCommand" :key="selected" />
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
<OSeparator class="my-4" />
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<!-- What it collects -->
<div class="text-sm font-medium pl-1">
Once installed, the collector will:
<ul class="list-disc ml-5 mt-1 font-normal space-y-0.5">
<li>Collect system logs and journald logs</li>
<li>Collect host metrics (CPU, memory, disk, network)</li>
<li v-if="selected === 'ec2'">Detect EC2 instance metadata automatically</li>
<li v-if="selected === 'ec2'">Use the EC2 Name tag as the host identifier in dashboards</li>
</ul>
</div>
</div>
<!--
Linux host agent setup page. Content lives in setupCard/content/linux.ts and
renders through the shared DataSourceSetupCard, the same card the Databases
and Kubernetes pages use.
Also mounted inside AWSIntegrationTile's EC2 dialog (see awsIntegrations.ts
componentOptions), so it must stay self-contained.
-->
<template>
<DataSourceSetupCard slug="linux" />
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import ContentCopy from "@/components/CopyContent.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import { useStore } from "vuex";
import { b64EncodeStandard, getEndPoint, getIngestionURL } from "../../../utils/zincutils";
import DataSourceSetupCard from "@/components/ingestion/setupCard/DataSourceSetupCard.vue";
const store = useStore();
const props = defineProps({
currOrgIdentifier: { type: String },
currUserEmail: { type: String },
});
const selected = ref("generic");
const options = [
{
value: "generic",
label: "Generic Linux",
description: "Any Linux server or VM",
icon: "terminal",
},
{
value: "ec2",
label: "AWS EC2",
description: "EC2 instances with Name tag detection",
icon: "cloud",
},
];
const endpoint: any = ref({ url: "", host: "", port: "", protocol: "", tls: "" });
const ingestionURL = getIngestionURL();
endpoint.value = getEndPoint(ingestionURL);
const accessKey = computed(() =>
b64EncodeStandard(`${props.currUserEmail}:${store.state.organizationData.organizationPasscode}`)
);
const activeCommand = computed(() => {
const base = selected.value === "ec2"
? "https://raw.githubusercontent.com/openobserve/agents/main/linux/ec2/install.sh"
: "https://raw.githubusercontent.com/openobserve/agents/main/linux/install.sh";
return `curl -O ${base} && chmod +x install.sh && sudo ./install.sh ${endpoint.value.url}/api/${props.currOrgIdentifier}/ [BASIC_PASSCODE]`;
});
const selectedCardClass = computed(() =>
store.state.theme === "dark"
? "border-[var(--q-primary)] bg-blue-950/30"
: "border-[var(--q-primary)] bg-blue-50"
);
const unselectedCardClass = computed(() =>
store.state.theme === "dark"
? "border-gray-700 bg-gray-800/40 hover:border-gray-500"
: "border-gray-200 bg-white hover:border-gray-300"
);
defineProps<{
currOrgIdentifier?: string;
currUserEmail?: string;
}>();
</script>
@@ -1,185 +1,121 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import { createRouter, createWebHistory } from 'vue-router';
import WindowsConfig from './WindowsConfig.vue';
import CopyContent from '@/components/CopyContent.vue';
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
vi.mock('../../../aws-exports', () => ({
default: { API_ENDPOINT: 'http://localhost:5080', region: 'us-east-1' },
import { describe, it, expect, vi, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { ref } from "vue";
import WindowsConfig from "./WindowsConfig.vue";
import windowsCard from "@/components/ingestion/setupCard/content/windows";
import linuxCard from "@/components/ingestion/setupCard/content/linux";
import { getDataSourceCard } from "@/components/ingestion/setupCard/registry";
const mockEndpoint = ref({
url: "https://test.openobserve.ai",
host: "test.openobserve.ai",
port: 443,
protocol: "https",
tls: true,
});
vi.mock("@/composables/useIngestion", () => ({
default: vi.fn(() => ({ endpoint: mockEndpoint })),
}));
vi.mock('../../../utils/zincutils', () => ({
getImageURL: vi.fn((path) => `mock-image-url-${path}`),
getEndPoint: vi.fn(() => ({ url: 'http://localhost:5080', host: 'localhost', port: '5080', protocol: 'http', tls: false })),
getIngestionURL: vi.fn(() => 'http://localhost:5080'),
b64EncodeStandard: vi.fn((str) => btoa(str)),
}));
vi.mock('@/components/CopyContent.vue', () => ({
vi.mock("@/components/ingestion/setupCard/SetupCardRenderer.vue", () => ({
default: {
name: 'CopyContent',
template: '<div data-test="copy-content">{{ content }}</div>',
props: ['content'],
name: "SetupCardRenderer",
props: ["content", "subs", "logoUrl", "logoUrlDark"],
template: '<div data-test="rich-card-stub" />',
},
}));
const mockStore = createStore({
state: {
selectedOrganization: { identifier: 'test-org', name: 'Test Organization' },
userInfo: { email: 'test@example.com' },
organizationData: { organizationPasscode: 'test-passcode' },
selectedOrganization: { identifier: "test-org", name: "Test Organization" },
userInfo: { email: "test@example.com" },
organizationData: { organizationPasscode: "test-passcode" },
theme: "light",
},
});
const mockI18n = createI18n({ locale: "en", messages: { en: {} } });
const mockI18n = createI18n({ locale: 'en', messages: { en: {} } });
const mockRouter = createRouter({ history: createWebHistory(), routes: [{ path: '/', component: { template: '<div>Home</div>' } }] });
const SUBS = {
url: "https://test.openobserve.ai",
org: "test-org",
token: "dGVzdEB0b2tlbg==",
};
describe('WindowsConfig.vue', () => {
let wrapper: VueWrapper;
beforeEach(() => {
vi.clearAllMocks();
describe("windowsCard builder", () => {
it("builds the Windows card metadata and step flow", () => {
const card = windowsCard(SUBS);
expect(card.provider.name).toBe("Windows");
expect(card.provider.metaBadges).toEqual(["Logs", "Metrics"]);
expect(card.steps.map((s) => s.id)).toEqual(["install", "verify"]);
});
it("uses PowerShell, not bash", () => {
const install = windowsCard(SUBS).steps[0];
expect(install.chip?.label).toBe("PowerShell");
for (const v of install.variants!) {
expect(v.code.lang).toBe("powershell");
expect(v.code.raw).toContain("Invoke-WebRequest");
expect(v.code.raw).toContain("-AUTH_KEY");
}
});
it("points each variant at its matching install script", () => {
const [generic, ec2] = windowsCard(SUBS).steps[0].variants!;
expect(generic.code.raw).toContain("/windows/install.ps1");
expect(generic.code.raw).not.toContain("/ec2/");
expect(ec2.code.raw).toContain("/windows/ec2/install.ps1");
expect(ec2.note).toContain("ec2:DescribeTags");
expect(generic.note).not.toContain("ec2:DescribeTags");
});
it("substitutes url/org and masks the ingestion token", () => {
const generic = windowsCard(SUBS).steps[0].variants![0];
expect(generic.code.raw).toContain(`${SUBS.url}/api/${SUBS.org}/`);
expect(generic.code.raw).not.toContain("[BASIC_PASSCODE]");
expect(generic.code.masked).not.toContain(SUBS.token);
});
it("adds the execution-policy guidance Linux does not need", () => {
const questions = windowsCard(SUBS).extras!.troubleshooting!.map((r) => r.q);
expect(questions[0]).toContain("fails to run");
// Shares the rest of its troubleshooting with the Linux card.
const linuxQs = linuxCard(SUBS).extras!.troubleshooting!.map((r) => r.q);
expect(questions).toEqual(expect.arrayContaining(linuxQs));
});
});
describe("WindowsConfig.vue", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
if (wrapper) wrapper.unmount();
});
const createWrapper = (props = {}) => {
const defaultProps = { currOrgIdentifier: 'test-org', currUserEmail: 'test@example.com' };
return mount(WindowsConfig, {
props: { ...defaultProps, ...props },
global: {
plugins: [mockI18n, mockRouter],
provide: { store: mockStore },
components: { CopyContent },
},
});
};
describe('Component Rendering', () => {
it('should mount without errors', () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it('should unmount without errors', () => {
wrapper = createWrapper();
expect(() => wrapper.unmount()).not.toThrow();
});
it('should render CopyContent component', () => {
wrapper = createWrapper();
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.exists()).toBe(true);
});
it('should render PowerShell instructions', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('PowerShell');
});
it('should render installation description', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Once installed');
});
it('should render Windows event log info', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('Windows Event Log');
});
it('should render metrics collection info', () => {
wrapper = createWrapper();
expect(wrapper.text()).toContain('performance counter');
});
});
describe('Props Validation', () => {
it('should accept currOrgIdentifier prop', () => {
wrapper = createWrapper({ currOrgIdentifier: 'my-org' });
expect(wrapper.exists()).toBe(true);
});
it('should accept currUserEmail prop', () => {
wrapper = createWrapper({ currUserEmail: 'user@test.com' });
expect(wrapper.exists()).toBe(true);
});
});
describe('activeCommand Computed', () => {
it('should generate a command string', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(typeof vm.activeCommand).toBe('string');
});
it('should include PowerShell command (Invoke-WebRequest)', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('Invoke-WebRequest');
});
it('should include install.ps1 in the command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('install.ps1');
});
it('should include the endpoint URL in the command', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('http://localhost:5080');
});
it('should include the org identifier in the command', () => {
wrapper = createWrapper({ currOrgIdentifier: 'my-org' });
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('my-org');
});
it('should include BASIC_PASSCODE placeholder', () => {
wrapper = createWrapper();
const vm = wrapper.vm as any;
expect(vm.activeCommand).toContain('[BASIC_PASSCODE]');
});
});
describe('CopyContent Integration', () => {
it('should pass getCommand to CopyContent', () => {
wrapper = createWrapper();
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.props('content')).toBeDefined();
expect(typeof copyContent.props('content')).toBe('string');
});
it('should pass command containing org identifier to CopyContent', () => {
wrapper = createWrapper({ currOrgIdentifier: 'test-org' });
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.props('content')).toContain('test-org');
});
it('should pass PowerShell command to CopyContent', () => {
wrapper = createWrapper();
const copyContent = wrapper.findComponent(CopyContent);
expect(copyContent.props('content')).toContain('Invoke-WebRequest');
});
});
describe('Utilities', () => {
it('should call getEndPoint on setup', async () => {
const zincutils = await import('../../../utils/zincutils');
wrapper = createWrapper();
expect(zincutils.getEndPoint).toHaveBeenCalled();
});
it('should call getIngestionURL on setup', async () => {
const zincutils = await import('../../../utils/zincutils');
wrapper = createWrapper();
expect(zincutils.getIngestionURL).toHaveBeenCalled();
it("renders the shared setup card for the windows slug", () => {
expect(getDataSourceCard("windows", SUBS)?.provider.name).toBe("Windows");
wrapper = mount(WindowsConfig, {
global: { plugins: [mockStore, mockI18n] },
});
const stub = wrapper.findComponent({ name: "SetupCardRenderer" });
expect(stub.exists()).toBe(true);
expect((stub.props("content") as any).provider.name).toBe("Windows");
});
});
@@ -1,118 +1,35 @@
<template>
<div class="p-3">
<!-- Environment selection cards -->
<div class="grid grid-cols-2 gap-3 mb-5">
<div
v-for="opt in options"
:key="opt.value"
class="rounded-lg border p-4 cursor-pointer transition-all flex gap-3 items-start"
:class="selected === opt.value ? selectedCardClass : unselectedCardClass"
@click="selected = opt.value"
>
<OIcon :name="opt.icon" size="lg" :class="selected === opt.value ? 'text-[var(--q-primary)]' : 'text-gray-400'" />
<div>
<div class="font-semibold text-sm">{{ opt.label }}</div>
<div class="text-xs mt-0.5" :class="store.state.theme === 'dark' ? 'text-gray-400' : 'text-gray-500'">{{ opt.description }}</div>
</div>
</div>
</div>
<!-- Copyright 2026 OpenObserve Inc.
<!-- EC2 IAM prerequisite note -->
<div
v-if="selected === 'ec2'"
class="flex gap-2 items-start rounded-lg p-3 mb-4 text-sm"
:class="store.state.theme === 'dark' ? 'bg-amber-950/40 border border-amber-800/50' : 'bg-amber-50 border border-amber-200'"
>
<OIcon name="info" size="sm" class="text-amber-500 shrink-0 mt-0.5" />
<div :class="store.state.theme === 'dark' ? 'text-amber-200' : 'text-amber-900'">
<span class="font-semibold">IAM role required.</span>
Attach an IAM role to your EC2 instance with
<code class="font-mono text-xs">ec2:DescribeTags</code> and
<code class="font-mono text-xs">ec2:DescribeInstances</code>
permissions. This allows the collector to read the instance Name tag and use it as the host identifier.
</div>
</div>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
<!-- Admin note -->
<div class="text-sm font-medium mb-2">
Run the following command in PowerShell as Administrator:
</div>
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
<!-- Install command -->
<ContentCopy :content="activeCommand" :key="selected" />
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<OSeparator class="my-4" />
<!--
Windows host agent setup page. Content lives in setupCard/content/windows.ts
and renders through the shared DataSourceSetupCard.
<!-- What it collects -->
<div class="text-sm font-medium pl-1">
Once installed, the collector will:
<ul class="list-disc ml-5 mt-1 font-normal space-y-0.5">
<li>Collect Windows Event Log (Application, Security, Setup, System)</li>
<li>Collect host metrics (CPU, memory, disk, network)</li>
<li>Collect Windows performance counter metrics</li>
<li v-if="selected === 'ec2'">Detect EC2 instance metadata automatically</li>
<li v-if="selected === 'ec2'">Use the EC2 Name tag as the host identifier in dashboards</li>
</ul>
</div>
</div>
Also mounted inside AWSIntegrationTile's EC2 dialog (see awsIntegrations.ts
componentOptions), so it must stay self-contained.
-->
<template>
<DataSourceSetupCard slug="windows" />
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import ContentCopy from "@/components/CopyContent.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import { useStore } from "vuex";
import { b64EncodeStandard, getEndPoint, getIngestionURL } from "../../../utils/zincutils";
import DataSourceSetupCard from "@/components/ingestion/setupCard/DataSourceSetupCard.vue";
const store = useStore();
const props = defineProps({
currOrgIdentifier: { type: String },
currUserEmail: { type: String },
});
const selected = ref("generic");
const options = [
{
value: "generic",
label: "Generic Windows",
description: "Any Windows server or VM",
icon: "desktop_windows",
},
{
value: "ec2",
label: "AWS EC2",
description: "EC2 instances with Name tag detection",
icon: "cloud",
},
];
const endpoint: any = ref({ url: "", host: "", port: "", protocol: "", tls: "" });
const ingestionURL = getIngestionURL();
endpoint.value = getEndPoint(ingestionURL);
const accessKey = computed(() =>
b64EncodeStandard(`${props.currUserEmail}:${store.state.organizationData.organizationPasscode}`)
);
const activeCommand = computed(() => {
const script = selected.value === "ec2"
? "https://raw.githubusercontent.com/openobserve/agents/main/windows/ec2/install.ps1"
: "https://raw.githubusercontent.com/openobserve/agents/main/windows/install.ps1";
return `Invoke-WebRequest -Uri ${script} -OutFile install.ps1 ; .\\install.ps1 -URL ${endpoint.value.url}/api/${props.currOrgIdentifier}/ -AUTH_KEY [BASIC_PASSCODE]`;
});
const selectedCardClass = computed(() =>
store.state.theme === "dark"
? "border-[var(--q-primary)] bg-blue-950/30"
: "border-[var(--q-primary)] bg-blue-50"
);
const unselectedCardClass = computed(() =>
store.state.theme === "dark"
? "border-gray-700 bg-gray-800/40 hover:border-gray-500"
: "border-gray-200 bg-white hover:border-gray-300"
);
defineProps<{
currOrgIdentifier?: string;
currUserEmail?: string;
}>();
</script>
@@ -0,0 +1,236 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Renderer-level tests for the two supplementary-section behaviours: the
// collapsed `extras.advanced` block, and the step-note jump links that open it.
// Driven by a synthetic content fixture so these stay independent of any one
// data source's copy.
import { describe, it, expect, vi, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router";
import SetupCardRenderer from "./SetupCardRenderer.vue";
import { iconRegistry } from "@/lib/core/Icon/OIcon.icons";
import type { RichCardContent } from "./types";
vi.mock("@/composables/useStreams", () => ({
default: () => ({ getStreams: vi.fn() }),
}));
const store = createStore({
state: {
selectedOrganization: { identifier: "test-org" },
userInfo: { email: "t@e.com" },
organizationData: { organizationPasscode: "pc" },
theme: "light",
},
});
const i18n = createI18n({ locale: "en", messages: { en: {} } });
const router = createRouter({
history: createWebHistory(),
routes: [{ path: "/", component: { template: "<div/>" } }],
});
const SUBS = { url: "https://o2.example.com", org: "test-org", token: "tok" };
const CONTENT: RichCardContent = {
provider: { name: "Demo", tagline: "A demo card.", logo: "", tone: "#000" },
steps: [
{
id: "install",
title: "Install",
description: "Run it.",
chip: { kind: "terminal", label: "Terminal" },
completeOn: "copy",
code: { lang: "bash", raw: "echo hi" },
note: "Prefer the long way? Go to [Manual Setup](#advanced).",
},
{
id: "verify",
title: "Verify",
description: "Check it.",
completeOn: "detect",
detectionAnchor: true,
},
],
detect: { streamType: "logs", streamName: "default", filter: "a IS NOT NULL" },
extras: {
advanced: {
label: "Manual Setup",
description: "The step-by-step path.",
code: { lang: "bash", raw: "echo manual-path" },
},
troubleshooting: [{ q: "It broke", a: "Turn it off and on." }],
},
};
const mountCard = (content: RichCardContent = CONTENT) =>
mount(SetupCardRenderer, {
props: { content, subs: SUBS },
global: { plugins: [store, i18n, router] },
attachTo: document.body,
});
describe("SetupCardRenderer — advanced section", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
if (wrapper) wrapper.unmount();
});
it("renders the advanced accordion collapsed, as its own panel", () => {
wrapper = mountCard();
const acc = wrapper.find('[data-test="ai-advanced-accordion"]');
expect(acc.exists()).toBe(true);
expect(acc.text()).toContain("Manual Setup");
// .acc-item carries the border/background so it reads as a real section
// rather than stray text at the bottom of a long card.
expect(acc.classes()).toContain("acc-item");
// Collapsed → Radix leaves the body unmounted entirely.
expect(wrapper.find('[data-test="ai-advanced-code"]').exists()).toBe(false);
});
it("uses icons registered in OIcon, never the material-font fallback", () => {
// An unregistered name silently degrades to a ligature span, which renders
// the raw word next to the label. Guard every accordion icon we pass.
for (const name of ["settings", "layers", "help-outline"]) {
expect(name in iconRegistry).toBe(true);
}
wrapper = mountCard();
const acc = wrapper.find('[data-test="ai-advanced-accordion"]');
expect(acc.find("svg").exists()).toBe(true);
expect(acc.find(".material-icons-outlined").exists()).toBe(false);
});
it("omits the accordion entirely when no advanced content is given", () => {
wrapper = mountCard({ ...CONTENT, extras: { troubleshooting: [] } });
expect(wrapper.find('[data-test="ai-advanced-accordion"]').exists()).toBe(
false,
);
});
});
describe("SetupCardRenderer — footer doc links", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
if (wrapper) wrapper.unmount();
});
it("renders every docLink as a real anchor beside the primary doc", () => {
wrapper = mountCard({
...CONTENT,
docUrl: "https://example.com/main",
docLinks: [
{ label: "Second Guide", url: "https://example.com/second" },
{ label: "Third Guide", url: "https://example.com/third" },
],
});
const hrefs = wrapper
.findAll(".pv-foot a")
.map((a) => a.attributes("href"));
expect(hrefs).toEqual([
"https://example.com/main",
"https://example.com/second",
"https://example.com/third",
]);
expect(wrapper.find('[data-test="ai-doc-link-second-guide"]').text()).toBe(
"Second Guide →",
);
});
it("renders only the primary link when there are no docLinks", () => {
wrapper = mountCard({ ...CONTENT, docUrl: "https://example.com/main" });
expect(wrapper.findAll(".pv-foot a")).toHaveLength(1);
});
it("refuses unsafe doc link hrefs", () => {
wrapper = mountCard({
...CONTENT,
docLinks: [{ label: "Evil", url: "javascript:alert(1)" }],
});
const evil = wrapper.find('[data-test="ai-doc-link-evil"]');
expect(evil.attributes("href")).toBe("#");
});
});
describe("SetupCardRenderer — step-note jump links", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
if (wrapper) wrapper.unmount();
});
it("renders [label](#advanced) as an anchor without leaking markdown", () => {
wrapper = mountCard();
const note = wrapper.find(".step-note");
expect(note.text()).not.toContain("](#advanced)");
expect(note.text()).not.toContain("[Manual Setup]");
const link = wrapper.find("a.note-jump");
expect(link.exists()).toBe(true);
expect(link.text()).toBe("Manual Setup");
expect(link.attributes("data-jump")).toBe("advanced");
});
it("opens and scrolls to the advanced section on click", async () => {
wrapper = mountCard();
const scrollSpy = vi.fn();
(Element.prototype as any).scrollIntoView = scrollSpy;
await wrapper.find("a.note-jump").trigger("click");
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const code = wrapper.find('[data-test="ai-advanced-code"]');
expect(code.exists()).toBe(true);
expect(code.text()).toContain("echo manual-path");
expect(scrollSpy).toHaveBeenCalled();
});
it("leaves ordinary notes untouched", () => {
const plain: RichCardContent = {
...CONTENT,
steps: [{ ...CONTENT.steps[0], note: "Just a plain note." }, CONTENT.steps[1]],
};
wrapper = mountCard(plain);
expect(wrapper.find(".step-note").text()).toContain("Just a plain note.");
expect(wrapper.find("a.note-jump").exists()).toBe(false);
});
it("does not turn arbitrary link targets into anchors", () => {
// Only the fixed #advanced / #troubleshooting alternation is linkified, so
// authored content cannot inject a URL through a note.
const evil: RichCardContent = {
...CONTENT,
steps: [
{
...CONTENT.steps[0],
note: "See [click me](javascript:alert(1)) and [x](#nope).",
},
CONTENT.steps[1],
],
};
wrapper = mountCard(evil);
const html = wrapper.find(".step-note").html();
// Neither target is linkified, so no anchor is produced at all...
expect(wrapper.find("a.note-jump").exists()).toBe(false);
expect(wrapper.find(".step-note a").exists()).toBe(false);
// ...and the payload survives only as inert escaped text, never an href.
expect(html).not.toContain('href="javascript:');
expect(wrapper.find(".step-note").text()).toContain("javascript:alert(1)");
});
});
@@ -64,6 +64,11 @@ const props = defineProps<{
logoUrlDark?: string;
}>();
const emit = defineEmits<{
/** A step's action button was clicked; carries RichCardStepAction.id. */
(e: "step-action", actionId: string): void;
}>();
const store = useStore();
const router = useRouter();
const { getStreams } = useStreams();
@@ -262,8 +267,22 @@ const currentVariantNote = (step: RichCardStep) =>
// ── step completion / active-step model ─────────────────────────────────────
const copied = ref<Record<string, boolean>>({});
const isStepDone = (step: RichCardStep) =>
step.completeOn === "copy" ? !!copied.value[step.id] : detected.value;
// Steps whose action button has been triggered (completeOn: "action") — the
// cloud-console flows have nothing to copy and nothing to detect, so the click
// itself is the completion signal.
const actioned = ref<Record<string, boolean>>({});
const isStepDone = (step: RichCardStep) => {
if (step.completeOn === "copy") return !!copied.value[step.id];
if (step.completeOn === "action") return !!actioned.value[step.id];
return detected.value;
};
const onStepAction = (step: RichCardStep, index: number) => {
if (step.completeOn === "action")
actioned.value = { ...actioned.value, [step.id]: true };
emit("step-action", step.action!.id);
scrollToStep(index + 1);
};
// First not-done step is "active"; -1 once everything is done. OStepper's
// model is 1-based step numbers (0 = none active, i.e. all done).
@@ -320,6 +339,31 @@ const inlineMd = (s: string) =>
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/`([^`]+)`/g, "<code>$1</code>");
// Step notes additionally support in-card jump links: `[label](#advanced)` and
// `[label](#troubleshooting)` render as anchors that OPEN the target accordion
// and scroll to it — the sections sit below the detection status bar, so a
// plain "see X below" leaves the user hunting. The href is a literal "#" and
// the target comes from a fixed alternation, so authored content can't inject a
// URL here (inlineMd has already escaped everything else).
const JUMP_LINK_RE = /\[([^\]]+)\]\(#(advanced|troubleshooting)\)/g;
const noteMd = (s: string) =>
inlineMd(s).replace(
JUMP_LINK_RE,
(_m, label, target) =>
`<a href="#" class="note-jump" data-jump="${target}">${label}</a>`,
);
// Delegated so the anchors rendered by v-html above stay clickable.
const onNoteClick = (e: MouseEvent) => {
const el = (e.target as HTMLElement)?.closest?.("[data-jump]") as
| HTMLElement
| null;
if (!el) return;
e.preventDefault();
if (el.dataset.jump === "advanced") openAdvanced();
else if (el.dataset.jump === "troubleshooting") openTroubleshooting();
};
const chipIcon = (kind: StepChipKind) =>
({ terminal: "", editor: "code", run: "play-arrow", traces: "timeline" })[
kind
@@ -337,18 +381,28 @@ const hasInstallerAccordion = computed(
() => !!(extras.value.installs?.length || extras.value.envVars?.length),
);
// "See All Troubleshooting" (from the fix box) opens the Troubleshooting
// accordion (controlled via v-model) and scrolls it into view.
const troubleshootingOpen = ref(false);
const troubleshootingRef = ref<any>(null);
const openTroubleshooting = () => {
troubleshootingOpen.value = true;
// Open a bottom accordion (controlled via v-model) and scroll it into view —
// used by the fix box's "See All Troubleshooting" and by step-note jump links.
const scrollIntoViewSoon = (target: { $el?: HTMLElement } | null) =>
nextTick(() =>
troubleshootingRef.value?.$el?.scrollIntoView({
target?.$el?.scrollIntoView({
behavior: prefersReducedMotion() ? "auto" : "smooth",
block: "start",
}),
);
const troubleshootingOpen = ref(false);
const troubleshootingRef = ref<any>(null);
const openTroubleshooting = () => {
troubleshootingOpen.value = true;
scrollIntoViewSoon(troubleshootingRef.value);
};
const advancedOpen = ref(false);
const advancedRef = ref<any>(null);
const openAdvanced = () => {
advancedOpen.value = true;
scrollIntoViewSoon(advancedRef.value);
};
// ── confetti (canvas burst on connect) ───────────────────────────────────────
@@ -584,13 +638,45 @@ function fireConfetti() {
</template>
</OCodeBlock>
<p v-if="currentVariantNote(step)" class="step-note">
<OIcon name="info-outline" size="sm" /> {{ currentVariantNote(step) }}
<p
v-if="currentVariantNote(step)"
class="step-note"
@click="onNoteClick"
>
<OIcon name="info-outline" size="sm" />
<span v-html="noteMd(currentVariantNote(step) || '')"></span>
</p>
<p v-if="step.note" class="step-note">
<OIcon name="info-outline" size="sm" /> {{ step.note }}
<p v-if="step.note" class="step-note" @click="onNoteClick">
<OIcon name="info-outline" size="sm" />
<span v-html="noteMd(step.note)"></span>
</p>
<!-- Page-supplied controls for this step (region pickers, service
checkboxes …). Lets a page own the interaction while inheriting
the card's hero, stepper and chrome instead of rebuilding them. -->
<div
v-if="$slots[`step-${step.id}`]"
class="step-slot"
:data-test="`ai-step-slot-${step.id}`"
>
<slot :name="`step-${step.id}`" :step="step" />
</div>
<!-- Action button — for steps performed in a cloud console rather
than by copying a command. -->
<div v-if="step.action" class="step-action">
<OButton
:variant="step.action.variant || 'primary'"
size="sm-action"
:icon-left="step.action.icon"
:disabled="step.action.disabled"
:data-test="`ai-step-action-${step.action.id}`"
@click="onStepAction(step, i)"
>
{{ step.action.label }}
</OButton>
</div>
<div v-if="step.pills?.length" class="pill-list mt-2">
<OTag
v-for="p in step.pills"
@@ -720,9 +806,39 @@ function fireConfetti() {
<!-- Supplementary accordions (shared OCollapsible) -->
<div
v-if="hasInstallerAccordion || extras.troubleshooting?.length"
v-if="hasInstallerAccordion || extras.advanced || extras.troubleshooting?.length"
class="c-more"
>
<!-- Alternative manual path (e.g. the raw Helm sequence) — collapsed, so
the stepped primary path above stays the obvious default. -->
<OCollapsible
v-if="extras.advanced"
ref="advancedRef"
v-model="advancedOpen"
:label="extras.advanced.label"
icon="settings"
class="acc-item"
data-test="ai-advanced-accordion"
>
<div class="acc-body">
<p
v-if="extras.advanced.description"
class="step-desc"
v-html="inlineMd(extras.advanced.description)"
></p>
<OCodeBlock
:lang="extras.advanced.code.lang"
:chrome="extras.advanced.code.filename ? 'editor' : 'terminal'"
:filename="extras.advanced.code.filename"
:code="subStream(extras.advanced.code.raw) || ''"
:code-masked="subStream(extras.advanced.code.masked)"
data-test="ai-advanced-code"
reveal-tooltip="Reveal Token"
hide-tooltip="Hide Token"
/>
</div>
</OCollapsible>
<OCollapsible
v-if="hasInstallerAccordion"
label="What The Installer Does"
@@ -789,6 +905,18 @@ function fireConfetti() {
rel="noopener noreferrer"
>{{ content.provider.name }} →</a
>
<!-- Secondary guides (e.g. GCP's Google Workspace page) — real anchors,
beside the primary doc link rather than buried in an accordion. -->
<template v-for="l in content.docLinks" :key="l.url">
<span class="pv-foot-sep" aria-hidden="true">·</span>
<a
:href="safeHttpUrl(l.url)"
target="_blank"
rel="noopener noreferrer"
:data-test="`ai-doc-link-${l.label.toLowerCase().replace(/\s+/g, '-')}`"
>{{ l.label }} →</a
>
</template>
<span v-if="content.slackUrl" class="ml-auto"
>Stuck?
<a
@@ -983,6 +1111,14 @@ function fireConfetti() {
margin: 10px 0 0;
}
/* ---- page-supplied step content + action button ---- */
.step-slot {
margin: 4px 0 14px;
}
.step-action {
margin-top: 14px;
}
/* ---- variant toggle (shared OToggleGroup) — only spacing + icon sizing here;
the toggle's own visuals come from the design system. ---- */
.variant-tabs {
@@ -1003,6 +1139,16 @@ function fireConfetti() {
flex: none;
margin-top: 1px;
}
/* In-card jump link (see noteMd) — same treatment as the footer's doc link. */
.step-note :deep(a.note-jump) {
color: var(--primary-ink);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.step-note :deep(a.note-jump:hover) {
text-decoration: underline;
}
.step-content-pad :deep(code),
.step-desc :deep(code) {
font-size: 12px;
@@ -1118,16 +1264,32 @@ function fireConfetti() {
margin-top: 13px;
}
/* ---- accordions (OCollapsible) ---- */
/* These sit at the very bottom of a long card, after the detection status bar.
OCollapsible's default trigger is borderless and background-less, which reads
as stray body text down there — give each one the same bordered panel as
.statusbar / .fixbox so it registers as a real, clickable section. */
.c-more {
margin-top: 14px;
margin-top: 22px;
}
.acc-item {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
overflow: hidden;
}
.acc-item + .acc-item {
margin-top: 8px;
margin-top: 10px;
}
/* Roomier hit area than the component's default px-2 py-2 inside a panel. */
.acc-item :deep(button) {
padding: 13px 16px;
border-radius: 0;
}
.acc-body {
color: var(--text-2);
font-size: 13px;
line-height: 1.6;
padding: 0 16px 16px;
}
.acc-body :deep(code) {
font-size: 11.5px;
@@ -1191,6 +1353,10 @@ function fireConfetti() {
.pv-foot a:hover {
text-decoration: underline;
}
.pv-foot-sep {
color: var(--text-3);
margin: 0 2px;
}
/* ---- confetti overlay ---- */
.dirC-confetti {
@@ -0,0 +1,144 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Google Cloud setup card. Unlike the agent cards, nothing is installed: GCP
// pushes to an OpenObserve HTTP endpoint via a Pub/Sub push subscription, so the
// card's job is to hand over the right URL and show where it goes in the GCP
// console.
//
// The stream is a `streamInput`, so the name the user picks flows into BOTH the
// push endpoint URL and the live detection — the page can't end up watching a
// different stream than the one GCP is writing to.
import { getImageURL } from "@/utils/zincutils";
import type { CardSubstitutions, RichCardContent } from "../types";
import { applySubs, applySubsMasked } from "../subs";
// {stream} is filled live by the renderer from the stream-name field.
const PUSH_ENDPOINT = "{url}/gcp/{org}/{stream}/_sub?API-Key={token}";
export default function gcpCard(subs: CardSubstitutions): RichCardContent {
return {
provider: {
name: "Google Cloud",
tagline:
"Stream Google Cloud logs into OpenObserve with a Pub/Sub push subscription — no agent or collector to run.",
logo: getImageURL("images/ingestion/gcp.svg"),
tone: "#4285f4",
runtime: "Google Cloud",
setupTime: "~5 min",
metaBadges: ["Logs"],
},
steps: [
{
id: "sink",
title: "Route Logs to a Pub/Sub Topic",
description:
"In the GCP console, create a **Log Router sink** (Logging → Log Router → Create sink) with Pub/Sub as the destination, and let it create the topic. The sink's inclusion filter decides which logs reach OpenObserve.",
chip: { kind: "editor", label: "GCP Console" },
required: true,
completeOn: "copy",
code: {
lang: "bash",
raw: `# Or from the CLI — create the topic, then the sink that feeds it
gcloud pubsub topics create openobserve-logs
gcloud logging sinks create openobserve-sink \\
pubsub.googleapis.com/projects/$(gcloud config get-value project)/topics/openobserve-logs \\
--log-filter='severity >= "DEFAULT"'`,
},
note: "Grant the sink's writer identity the Pub/Sub Publisher role on the topic, or it will silently publish nothing.",
},
{
id: "subscription",
title: "Push the Topic to OpenObserve",
description:
"Create a **push** subscription on that topic and paste this as the endpoint URL. It already carries this organization and its ingestion key.",
chip: { kind: "terminal", label: "Endpoint" },
required: true,
completeOn: "copy",
code: {
lang: "bash",
raw: applySubs(PUSH_ENDPOINT, subs),
masked: applySubsMasked(PUSH_ENDPOINT, subs),
},
note: "Keep this URL secret — it embeds the ingestion key. Rotate it from this page's header if it leaks.",
},
{
id: "verify",
title: "Verify Data in OpenObserve",
description:
"Generate some activity in the project (or wait for the sink to match a log), then hit Test.",
chip: { kind: "traces", label: "Logs" },
completeOn: "detect",
detectionAnchor: true,
pills: ["Audit Logs", "Cloud Run", "GKE", "Cloud Functions", "VPC Flow"],
},
],
streamInput: {
label: "Logs Stream Name",
default: "default",
placeholder: "gcp_logs",
help: "Used in the push URL above and watched by the check below. A dedicated stream keeps GCP logs separate.",
},
// The push subscription writes to exactly this stream, so any row on it in
// the lookback window is proof the pipeline works end to end.
detect: {
streamType: "logs",
streamName: "default",
filter: "_timestamp IS NOT NULL",
},
extras: {
fixTitle: "Check The Push Subscription",
fixBody:
"GCP reports delivery failures on the subscription itself. If the sink is publishing but nothing lands here, the push endpoint is usually rejecting the request:",
fixLang: "bash",
fixSnippet: `gcloud pubsub subscriptions describe openobserve-push \\
--format='value(pushConfig.pushEndpoint)'
# Unacked messages piling up means the endpoint is erroring
gcloud pubsub subscriptions describe openobserve-push \\
--format='value(numUndeliveredMessages)'`,
troubleshooting: [
{
q: "The subscription shows delivery errors",
a: "A 401 means the `API-Key` in the URL is stale — re-copy the endpoint from this page. A 404 usually means the org or stream segment was edited by hand; the URL above is already correct for this organization.",
},
{
q: "The sink exists but the topic receives nothing",
a: "The sink's **writer identity** needs the Pub/Sub Publisher role on the topic. GCP creates the sink successfully without it and then drops everything, silently.",
},
{
q: "Only some logs arrive",
a: "That is the sink's inclusion filter, not OpenObserve. Widen the `--log-filter` on the sink to cover the services and severities you expect.",
},
{
q: "Can I send Google Workspace audit logs too?",
a: "Yes — Workspace exports into Cloud Logging, so the same Log Router sink picks it up once the export is enabled. Open the **Google Workspace** guide linked at the bottom of this page for the export setup.",
},
],
},
docUrl: "https://openobserve.ai/blog/send-gcp-logs-to-openobserve",
// Both guides the page linked before the migration. Google Workspace is a
// distinct source that rides the same Pub/Sub pipeline, so it belongs here
// as a first-class link, not as prose.
docLinks: [
{
label: "Google Workspace",
url: "https://short.openobserve.ai/security/google-workspace",
},
],
};
}
@@ -0,0 +1,324 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Kubernetes data-source setup card. The collector ships logs, k8s events and
// cluster/pod metrics, and (via the OpenTelemetry operator) can auto-instrument
// workloads for traces with no code change — so the card has three steps:
// install → optionally annotate for traces → verify.
//
// Step 1 deliberately offers ONLY the two endpoint choices — external (reach
// OpenObserve over the network) and internal (OpenObserve runs in this same
// cluster, so traffic never leaves it) — because the one-line installer is the
// recommended path and should read as the default. The two use different flags
// (`--o2-url` vs `--internal-endpoint`), so they are distinct commands rather
// than one editable URL field. Internal is self-hosted only: on cloud there is
// no in-cluster router to talk to, so the toggle disappears entirely.
//
// The raw Helm sequence stays a secondary, collapsed path (extras.advanced),
// mirroring the "Advanced Installation" accordion it replaces.
import config from "@/aws-exports";
import { getImageURL } from "@/utils/zincutils";
import type {
CardSubstitutions,
RichCardContent,
RichCardStepVariant,
} from "../types";
import { applySubs, applySubsMasked } from "../subs";
/** Pinned cert-manager release the manual sequence installs. Bump in one place. */
const CERT_MANAGER_VERSION = "1.19.0";
/**
* In-cluster address of the OpenObserve router, used when OpenObserve runs in
* the same cluster as the collector. Shape is
* `http://<helm-release>-openobserve-router.<namespace>.svc.cluster.local:5080`
* — the default below assumes release `o2` in namespace `openobserve`.
*/
const IN_CLUSTER_URL =
"http://o2-openobserve-router.openobserve.svc.cluster.local:5080";
const INSTALL_SCRIPT =
"https://raw.githubusercontent.com/openobserve/o2-datasource/main/k8s/install.sh";
// ── install commands ─────────────────────────────────────────────────────────
// {cluster} is a live step input; {org}/{token} are substituted at build time.
// `--o2-url` takes a reachable URL, `--internal-endpoint` the in-cluster one —
// they are separate flags, not two values for the same flag.
const scriptInstall = (endpointFlag: string) =>
`curl -sSL ${INSTALL_SCRIPT} | bash -s -- \\
--cluster-name={cluster} \\
--org-id={org} \\
--access-key={token} \\
${endpointFlag}`;
// The Helm path as ONE copy-paste block. Previously six separate copy boxes with
// a "wait 2 minutes" instruction between them; the explicit `kubectl wait` on
// the webhook makes that deterministic instead of a guess.
const helmInstall = (exporterUrl: string) =>
`# 1 — cert-manager (the OpenTelemetry operator's webhook depends on it)
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v${CERT_MANAGER_VERSION}/cert-manager.yaml
kubectl wait --for=condition=Available --timeout=300s \\
-n cert-manager deployment/cert-manager-webhook
# 2 — Prometheus operator CRDs (required by the OpenTelemetry operator)
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/main/example/prometheus-operator-crd/monitoring.coreos.com_servicemonitors.yaml
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/main/example/prometheus-operator-crd/monitoring.coreos.com_podmonitors.yaml
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/refs/heads/main/example/prometheus-operator-crd/monitoring.coreos.com_scrapeconfigs.yaml
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/refs/heads/main/example/prometheus-operator-crd/monitoring.coreos.com_probes.yaml
# 3 — OpenTelemetry operator
kubectl apply -f https://raw.githubusercontent.com/openobserve/openobserve-helm-chart/refs/heads/main/opentelemetry-operator.yaml
# 4 — OpenObserve collector
helm repo add openobserve https://charts.openobserve.ai
helm repo update
kubectl create ns openobserve-collector
helm --namespace openobserve-collector \\
upgrade --install o2c openobserve/openobserve-collector \\
--set k8sCluster={cluster} \\
--set exporters.'otlphttp/openobserve'.endpoint=${exporterUrl}/api/{org} \\
--set exporters.'otlphttp/openobserve'.headers.Authorization='Basic {token}' \\
--set exporters.'otlphttp/openobserve_k8s_events'.endpoint=${exporterUrl}/api/{org} \\
--set exporters.'otlphttp/openobserve_k8s_events'.headers.Authorization='Basic {token}'`;
/** Build a variant whose code carries the org token (masked until revealed). */
const codeFor = (
template: string,
subs: CardSubstitutions,
lang = "bash",
) => ({
lang,
raw: applySubs(template, subs),
masked: applySubsMasked(template, subs),
});
// ── auto-instrumentation (optional step) ─────────────────────────────────────
/**
* Annotating a namespace makes the operator inject the language's auto-
* instrumentation into every pod in it — no application code or image change.
* Go is the exception: its eBPF instrumentation also needs the path to the
* compiled binary inside the container.
*/
const annotate = (key: string, extra = "") =>
`# Every pod in the namespace is instrumented — no code or image change.
kubectl annotate namespace my-namespace \\
instrumentation.opentelemetry.io/inject-${key}="openobserve-collector/openobserve-${key}"${extra}
# Restart so running pods pick up the injection
kubectl rollout restart deployment -n my-namespace`;
const LANGUAGES: { id: string; label: string; icon: string; extra?: string }[] =
[
{ id: "java", label: "Java", icon: "images/ingestion/java.svg" },
{ id: "dotnet", label: ".NET", icon: "images/ingestion/dotnet.svg" },
{ id: "nodejs", label: "Node.js", icon: "images/ingestion/nodejs.svg" },
{ id: "python", label: "Python", icon: "images/ingestion/python.svg" },
{
id: "go",
label: "Go (eBPF)",
icon: "images/ingestion/golang.svg",
// eBPF attaches to the binary, so the operator needs its in-container path.
extra: ` \\
instrumentation.opentelemetry.io/otel-go-auto-target-exe="/path/to/container/executable"`,
},
];
const instrumentVariants = (): RichCardStepVariant[] =>
LANGUAGES.map((l) => ({
id: l.id,
label: l.label,
icon: getImageURL(l.icon),
code: { lang: "bash", raw: annotate(l.id, l.extra) },
note:
l.id === "go"
? "Point otel-go-auto-target-exe at the compiled binary inside the container — eBPF attaches to that executable, not to the pod."
: "Annotate a single pod spec instead of the namespace to instrument one workload at a time.",
}));
// ── card ─────────────────────────────────────────────────────────────────────
export default function kubernetesCard(
subs: CardSubstitutions,
): RichCardContent {
const isCloud = config.isCloud === "true";
const INSTALLER_NOTE =
"This installs cert-manager, the OpenTelemetry operator and the OpenObserve collector automatically.";
// The manual Helm path sits in a collapsed section below the steps, which is
// past the auto-instrument and verify steps — point at it from here, where the
// user is actually choosing how to install. `(#advanced)` renders as a jump
// link that opens that accordion and scrolls to it (see noteMd in
// SetupCardRenderer), so the user never has to go looking for it.
const ADVANCED_HINT =
"Prefer Helm, or need to pin versions and customise chart values? Go to [Advanced Installation (Manual Steps)](#advanced).";
const externalCode = codeFor(scriptInstall("--o2-url={url}"), subs);
// Cloud has no in-cluster router, so there is nothing to choose between —
// the step renders a single command with no toggle at all.
const installVariants: RichCardStepVariant[] | undefined = isCloud
? undefined
: [
{
id: "external",
label: "External Endpoint",
code: externalCode,
note: `${INSTALLER_NOTE} ${ADVANCED_HINT}`,
},
{
id: "internal",
label: "Internal Endpoint",
code: codeFor(
scriptInstall(`--internal-endpoint=${IN_CLUSTER_URL}`),
subs,
),
note: `Use this when OpenObserve runs in this same cluster — traffic never leaves it. ${INSTALLER_NOTE} ${ADVANCED_HINT}`,
},
];
return {
provider: {
name: "Kubernetes",
tagline:
"Deploy the OpenObserve collector to your cluster — container logs, Kubernetes events and cluster metrics, plus zero-code traces for your workloads.",
logo: getImageURL("images/common/kubernetes.svg"),
tone: "#326ce5",
runtime: "Cluster",
setupTime: "~3 min",
metaBadges: ["Logs", "Metrics", "Events", "Traces"],
},
steps: [
{
id: "install",
// "(Recommended)" is carried in the title, as it was on the page this
// replaces — it is the path the overwhelming majority should take, and
// the manual Helm sequence below must not read as an equal alternative.
title: "Quick Install (Recommended)",
description:
"Install the OpenObserve collector with a **single command** — just set your cluster name and run. The name tags every record, so you can tell clusters apart once several are reporting.",
chip: { kind: "terminal", label: "Terminal" },
required: true,
completeOn: "copy",
inputs: [
{
id: "cluster",
label: "Cluster Name",
default: "cluster1",
placeholder: "production",
help: "Identifies this cluster in your logs, metrics and dashboards.",
},
],
// Cloud: one command, no toggle. Self-hosted: external / internal.
// The note rides on each variant when there are variants, so it only
// lives on the step itself in the cloud (no-variant) case.
code: isCloud ? externalCode : undefined,
note: isCloud ? `${INSTALLER_NOTE} ${ADVANCED_HINT}` : undefined,
variants: installVariants,
},
{
id: "instrument",
title: "Auto-Instrument Your Applications for Traces",
description:
"Optional. The OpenTelemetry operator injects instrumentation into annotated workloads, so you get **distributed traces without touching application code or rebuilding images**. Pick a language, then annotate the namespace it runs in.",
chip: { kind: "editor", label: "Optional" },
completeOn: "copy",
variants: instrumentVariants(),
},
{
id: "verify",
title: "Verify Data in OpenObserve",
description:
"Give the collector a few seconds to start, then hit Test — cluster logs land in the `default` stream.",
chip: { kind: "traces", label: "Logs" },
completeOn: "detect",
detectionAnchor: true,
pills: [
"Container Logs",
"Kubernetes Events",
"Node Metrics",
"Pod Metrics",
"Traces",
],
},
],
// Collector logs carry the k8sattributes processor's resource attributes;
// `k8s_namespace_name` is the flattened `k8s.namespace.name`, which is what
// distinguishes cluster logs from anything else already on `default`.
detect: {
streamType: "logs",
streamName: "default",
filter: "k8s_namespace_name IS NOT NULL",
},
extras: {
// The manual Helm path, kept secondary. Previously six separate copy
// boxes with a "wait 2 minutes" instruction between them; the explicit
// `kubectl wait` on the webhook makes that deterministic.
advanced: {
label: "Advanced Installation (Manual Steps)",
description: isCloud
? "For custom configurations, or to pin versions and customise the chart values. Run the steps in order."
: `For custom configurations, or to pin versions and customise the chart values. Run the steps in order. If OpenObserve runs in this same cluster, replace the exporter endpoints with \`${IN_CLUSTER_URL}\`.`,
code: codeFor(helmInstall("{url}"), subs),
},
fixTitle: "Wait For The cert-manager Webhook",
fixBody:
"The OpenTelemetry operator fails to start until cert-manager's webhook is serving, and a collector that never starts sends nothing. Check that both are running, then test again:",
fixLang: "bash",
fixSnippet: `kubectl wait --for=condition=Available --timeout=300s \\
-n cert-manager deployment/cert-manager-webhook
kubectl get pods -n openobserve-collector`,
troubleshooting: [
{
q: "The install fails with an error about the admission webhook",
a: "The OpenTelemetry operator's webhook needs cert-manager's webhook to be serving first. Run `kubectl wait --for=condition=Available --timeout=300s -n cert-manager deployment/cert-manager-webhook`, then re-run the install — it is idempotent.",
},
{
q: "The collector pods are running but nothing arrives",
a: "Check the collector's own logs with `kubectl logs -n openobserve-collector -l app.kubernetes.io/name=openobserve-collector`. A 401 means the access key is stale — re-copy the command from this page. A connection timeout usually means the cluster cannot reach the endpoint: if OpenObserve runs in this same cluster, switch to an **In-Cluster** variant above.",
},
{
q: "Which endpoint should I use — external or in-cluster?",
a: `Use **In-Cluster** only when OpenObserve itself runs in this cluster; traffic then stays inside it and never crosses the network. The URL is \`http://<helm-release>-openobserve-router.<namespace>.svc.cluster.local:5080\` — the default assumes release \`o2\` in namespace \`openobserve\`, so edit it if yours differ. Everyone else uses the external endpoint.`,
},
{
q: "I annotated a namespace but no traces appear",
a: "Injection happens at pod creation, so existing pods are unaffected until they restart — run `kubectl rollout restart deployment -n my-namespace`. Confirm the annotation landed with `kubectl get ns my-namespace -o yaml`, and check that the value matches `openobserve-collector/openobserve-<language>`. For Go, `otel-go-auto-target-exe` must be the binary's path **inside** the container.",
},
{
q: "Can I see a working example?",
// inlineMd renders **bold** and `code` only, so both references are
// real links in the footer (docLinks) rather than text here.
a: "Yes — **HOT commerce** is a sample application wired up end to end, and the **OpenTelemetry Operator** docs carry the full annotation reference. Both are linked at the bottom of this page.",
},
],
},
docUrl: "https://github.com/openobserve/openobserve-helm-chart",
// Both links the page carried before the migration, restored as anchors.
docLinks: [
{
label: "HOT Commerce Example",
url: "https://github.com/openobserve/hotcommerce",
},
{
label: "OpenTelemetry Operator",
url: "https://github.com/open-telemetry/opentelemetry-operator",
},
],
};
}
@@ -0,0 +1,108 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Linux host agent setup card. The environment choice (generic host vs AWS EC2)
// was previously two hand-built clickable <div> cards plus a conditional amber
// callout; it is now a variant toggle, with the EC2 IAM prerequisite riding on
// the EC2 variant's note where it is actually relevant.
import { getImageURL } from "@/utils/zincutils";
import type { CardSubstitutions, RichCardContent } from "../types";
import {
AGENTS_REPO,
EC2_IAM_NOTE,
agentCode,
envIcons,
hostMetricsDetect,
sharedAgentTroubleshooting,
} from "./osAgent";
const install = (env: "" | "/ec2") =>
`curl -O ${AGENTS_REPO}/linux${env}/install.sh \\
&& chmod +x install.sh \\
&& sudo ./install.sh {url}/api/{org}/ {token}`;
export default function linuxCard(subs: CardSubstitutions): RichCardContent {
const icon = envIcons();
return {
provider: {
name: "Linux",
tagline:
"Install the OpenObserve agent on any Linux host — system and journald logs plus CPU, memory, disk and network metrics.",
logo: getImageURL("images/common/linux.svg"),
tone: "#f5b53d",
runtime: "Host",
setupTime: "~1 min",
metaBadges: ["Logs", "Metrics"],
},
steps: [
{
id: "install",
title: "Install the Agent",
description:
"Run this on the host as **root** (or with `sudo`). Pick **AWS EC2** to additionally pick up instance metadata and use the instance's Name tag as the host identifier.",
chip: { kind: "terminal", label: "Terminal" },
required: true,
completeOn: "copy",
variants: [
{
id: "generic",
label: "Generic Linux",
icon: icon.linux,
code: agentCode(install(""), subs, "bash"),
note: "Any Linux server or VM.",
},
{
id: "ec2",
label: "AWS EC2",
icon: icon.ec2,
code: agentCode(install("/ec2"), subs, "bash"),
note: EC2_IAM_NOTE,
},
],
},
{
id: "verify",
title: "Verify Data in OpenObserve",
description:
"The agent starts on install. Give it a few seconds, then hit Test — host metrics arrive as `system_*` streams.",
chip: { kind: "traces", label: "Metrics" },
completeOn: "detect",
detectionAnchor: true,
pills: [
"System Logs",
"journald",
"CPU",
"Memory",
"Disk",
"Network",
],
},
],
detect: hostMetricsDetect,
extras: {
fixTitle: "Check The Agent Service",
fixBody:
"The installer starts the agent as a systemd service. If nothing arrives, confirm it is running and read its logs for an auth or connectivity error:",
fixLang: "bash",
fixSnippet: `sudo systemctl status openobserve-agent
sudo journalctl -u openobserve-agent -n 50 --no-pager`,
troubleshooting: sharedAgentTroubleshooting(
"`sudo systemctl status openobserve-agent`",
),
},
docUrl: "https://github.com/openobserve/agents",
};
}
@@ -0,0 +1,90 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Shared pieces for the host-agent cards (Linux, Windows). Both install the same
// OpenObserve agent from the openobserve/agents repo and differ only by shell,
// script path and what the agent collects — so the environment choice
// (generic host vs AWS EC2), the EC2 IAM prerequisite and the detection config
// live here rather than being duplicated in both cards.
import { getImageURL } from "@/utils/zincutils";
import type { CardSubstitutions, RichCardDetect, RichCardExtras } from "../types";
import { applySubs, applySubsMasked } from "../subs";
/** Base of the agent install scripts, e.g. `${AGENTS_REPO}/linux/install.sh`. */
export const AGENTS_REPO =
"https://raw.githubusercontent.com/openobserve/agents/main";
/** Environment the agent is installed onto — drives the script path. */
export type AgentEnv = "generic" | "ec2";
/**
* The EC2 variants read the instance's Name tag and use it as the host
* identifier, which needs an instance role — without it the agent installs but
* every host shows up under its opaque instance id instead.
*/
export const EC2_IAM_NOTE =
"Requires an IAM role on the instance with ec2:DescribeTags and ec2:DescribeInstances, so the agent can read the Name tag and use it as the host identifier.";
/** Icon shown on the generic/EC2 toggle. */
export const envIcons = () => ({
ec2: getImageURL("images/ingestion/aws.svg"),
linux: getImageURL("images/common/linux.svg"),
windows: getImageURL("images/common/windows.svg"),
});
/** Build a code block whose `[BASIC_PASSCODE]`-equivalent token is masked. */
export function agentCode(template: string, subs: CardSubstitutions, lang: string) {
return {
lang,
raw: applySubs(template, subs),
masked: applySubsMasked(template, subs),
};
}
/**
* Host metrics land as one stream per metric (`system_cpu_time`, …), so — like
* the database cards — detection is keyword/existence based rather than a COUNT
* over one stream.
*
* TODO(detect): confirm the agent's emitted metric prefix on the ingest side;
* this assumes the OTel hostmetrics receiver's `system.*` naming.
*/
export const hostMetricsDetect: RichCardDetect = {
streamType: "metrics",
match: "keyword",
streamName: "system",
filter: "",
};
/** Troubleshooting rows common to both host agents. */
export function sharedAgentTroubleshooting(
serviceHint: string,
): NonNullable<RichCardExtras["troubleshooting"]> {
return [
{
q: "The install script runs but no data arrives",
a: `Check the agent is running with ${serviceHint}. A 401 in its logs means the ingestion token is stale — re-copy the command from this page, which always carries this organization's current token.`,
},
{
q: "Hosts show up under an instance id instead of a name",
a: `That is the EC2 variant without its IAM role. ${EC2_IAM_NOTE} Attach the role, then restart the agent.`,
},
{
q: "The endpoint is unreachable from the host",
a: "The agent needs outbound access to this OpenObserve endpoint. Confirm with `curl -v` from the host itself — a proxy or egress firewall is the usual cause.",
},
];
}
@@ -0,0 +1,187 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// OTLP traces setup card — the generic "point any OpenTelemetry exporter here"
// page. There is nothing to install: the user already has an SDK or collector,
// and needs the endpoint, the auth header and the stream name.
//
// The stream is a `streamInput`, so the name flows into the exporter config AND
// the live detection — they can never drift apart.
import config from "@/aws-exports";
import { getImageURL } from "@/utils/zincutils";
import type {
CardSubstitutions,
RichCardContent,
RichCardStepVariant,
} from "../types";
import { applySubs, applySubsMasked } from "../subs";
// Collector exporter config. {stream} is filled live by the renderer.
const HTTP_YAML = `exporters:
otlphttp/openobserve:
endpoint: {url}/api/{org}
headers:
Authorization: Basic {token}
stream-name: {stream}
service:
pipelines:
traces:
exporters: [otlphttp/openobserve]`;
const grpcYaml = (host: string, insecure: boolean) => `exporters:
otlp/openobserve:
endpoint: ${host}:5081
headers:
Authorization: "Basic {token}"
organization: {org}
stream-name: {stream}
tls:
insecure: ${insecure}
service:
pipelines:
traces:
exporters: [otlp/openobserve]`;
// Env vars for an SDK pointed straight at OpenObserve (no collector in between).
const SDK_ENV = `export OTEL_EXPORTER_OTLP_ENDPOINT="{url}/api/{org}"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic {token},stream-name={stream}"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_SERVICE_NAME="my-service"`;
export default function otlpTracesCard(
subs: CardSubstitutions,
): RichCardContent {
const isCloud = config.isCloud === "true";
// The gRPC endpoint is a self-hosted port; cloud terminates HTTP only.
const host = subs.url.replace(/^https?:\/\//, "").replace(/\/$/, "");
const insecure = !subs.url.startsWith("https://");
const code = (tpl: string, lang = "yaml") => ({
lang,
raw: applySubs(tpl, subs),
masked: applySubsMasked(tpl, subs),
});
const variants: RichCardStepVariant[] = [
{
id: "http",
label: "Collector · OTLP HTTP",
icon: getImageURL("images/ingestion/otlp.svg"),
code: { ...code(HTTP_YAML), filename: "config.yaml" },
note: "The default for the OpenTelemetry Collector — works everywhere, including behind proxies and load balancers.",
},
{
id: "sdk",
label: "SDK (No Collector)",
icon: getImageURL("images/rum/events/terminal.png"),
code: code(SDK_ENV, "bash"),
note: "Standard OTEL_* environment variables — every OpenTelemetry SDK reads these, so no code change is needed.",
},
];
// gRPC is self-hosted only.
if (!isCloud) {
variants.splice(1, 0, {
id: "grpc",
label: "Collector · OTLP gRPC",
icon: getImageURL("images/ingestion/otlp.svg"),
code: { ...code(grpcYaml(host, insecure)), filename: "config.yaml" },
note: "Lower overhead than HTTP for high span volumes. Port 5081 must be reachable from the collector.",
});
}
return {
provider: {
name: "Traces (OpenTelemetry)",
tagline:
"Point any OpenTelemetry exporter at OpenObserve — collector or SDK, HTTP or gRPC. Your endpoint and token are filled in below.",
logo: getImageURL("images/ingestion/otlp.svg"),
tone: "#f5a800",
runtime: "Any",
setupTime: "~2 min",
metaBadges: ["Traces"],
},
steps: [
{
id: "configure",
title: "Point Your Exporter at OpenObserve",
description:
"Add this to your collector config, or set the environment variables if your app exports directly. Everything is already filled in for this organization.",
chip: { kind: "editor", label: "Editor" },
required: true,
completeOn: "copy",
variants,
},
{
id: "verify",
title: "Verify Data in OpenObserve",
description:
"Restart the collector (or your app), exercise a request that produces a span, then hit Test.",
chip: { kind: "traces", label: "Traces" },
completeOn: "detect",
detectionAnchor: true,
pills: ["Spans", "Service Map", "Latency", "Errors"],
},
],
streamInput: {
label: "Traces Stream Name",
default: "default",
placeholder: "default",
help: "Written into the config above as `stream-name`, and watched by the check below.",
},
// Every span carries a trace id, so any row on the target stream proves the
// exporter is wired up correctly.
detect: {
streamType: "traces",
streamName: "default",
filter: "trace_id IS NOT NULL",
},
extras: {
fixTitle: "Check The Exporter Is Actually Wired Into The Pipeline",
fixBody:
"Defining an exporter is not enough — it has to be listed in a pipeline. This is the single most common reason a correct-looking config sends nothing:",
fixLang: "yaml",
fixSnippet: `service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/openobserve] # <- must be listed here`,
troubleshooting: [
{
q: "The collector logs 401 or 403",
a: "The `Authorization` header must be `Basic <token>` using this organization's ingestion token — the value above is already correct. If it was rotated, re-copy from this page.",
},
{
q: "Spans arrive but land in the wrong stream",
a: "The `stream-name` header decides the stream. Set it to the same name as the field above, otherwise the check below watches a stream nothing is written to.",
},
{
q: "gRPC connection fails with a TLS error",
a: `The \`tls.insecure\` flag must match the endpoint's scheme — it is set to \`${insecure}\` above based on this deployment's URL. A plain-HTTP endpoint needs \`insecure: true\`; an HTTPS one needs \`false\`.`,
},
{
q: "Should I use HTTP or gRPC?",
a: "HTTP is the safe default and traverses proxies and load balancers cleanly. gRPC has lower overhead at high span volume but needs port 5081 reachable end to end.",
},
],
},
docUrl:
"https://openobserve.ai/docs/user-guide/data-sources-ingestion/traces/opentelemetry/",
};
}
@@ -0,0 +1,111 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Windows host agent setup card — the PowerShell twin of ./linux.ts. Shared
// scaffolding (environment toggle, EC2 IAM prerequisite, host-metrics detection)
// comes from ./osAgent; this file holds the PowerShell command and the
// Windows-specific collection list.
import { getImageURL } from "@/utils/zincutils";
import type { CardSubstitutions, RichCardContent } from "../types";
import {
AGENTS_REPO,
EC2_IAM_NOTE,
agentCode,
envIcons,
hostMetricsDetect,
sharedAgentTroubleshooting,
} from "./osAgent";
const install = (env: "" | "/ec2") =>
`Invoke-WebRequest -Uri ${AGENTS_REPO}/windows${env}/install.ps1 -OutFile install.ps1
.\\install.ps1 -URL {url}/api/{org}/ -AUTH_KEY {token}`;
export default function windowsCard(subs: CardSubstitutions): RichCardContent {
const icon = envIcons();
return {
provider: {
name: "Windows",
tagline:
"Install the OpenObserve agent on any Windows host — Event Log, performance counters and host metrics.",
logo: getImageURL("images/common/windows.svg"),
tone: "#0078d4",
runtime: "Host",
setupTime: "~1 min",
metaBadges: ["Logs", "Metrics"],
},
steps: [
{
id: "install",
title: "Install the Agent",
description:
"Run this in **PowerShell as Administrator**. Pick **AWS EC2** to additionally pick up instance metadata and use the instance's Name tag as the host identifier.",
chip: { kind: "terminal", label: "PowerShell" },
required: true,
completeOn: "copy",
variants: [
{
id: "generic",
label: "Generic Windows",
icon: icon.windows,
code: agentCode(install(""), subs, "powershell"),
note: "Any Windows server or VM.",
},
{
id: "ec2",
label: "AWS EC2",
icon: icon.ec2,
code: agentCode(install("/ec2"), subs, "powershell"),
note: EC2_IAM_NOTE,
},
],
},
{
id: "verify",
title: "Verify Data in OpenObserve",
description:
"The agent starts on install. Give it a few seconds, then hit Test — host metrics arrive as `system_*` streams.",
chip: { kind: "traces", label: "Metrics" },
completeOn: "detect",
detectionAnchor: true,
pills: [
"Application",
"Security",
"Setup",
"System",
"Performance Counters",
"Host Metrics",
],
},
],
detect: hostMetricsDetect,
extras: {
fixTitle: "Check The Agent Service",
fixBody:
"The installer registers the agent as a Windows service. If nothing arrives, confirm it is running and read the most recent entries from its log:",
fixLang: "powershell",
fixSnippet: `Get-Service -Name openobserve-agent
Get-Content -Tail 50 "$env:ProgramData\\openobserve-agent\\agent.log"`,
troubleshooting: [
{
q: "The script fails to run at all",
a: "PowerShell blocks unsigned scripts by default. Run the console **as Administrator** and, if needed, allow the script for that session with `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass`.",
},
...sharedAgentTroubleshooting("`Get-Service -Name openobserve-agent`"),
],
},
docUrl: "https://github.com/openobserve/agents",
};
}
@@ -35,6 +35,11 @@ import snowflake from "./content/snowflake";
import cassandra from "./content/cassandra";
import databricks from "./content/databricks";
import dynamodb from "./content/dynamodb";
import kubernetes from "./content/kubernetes";
import linux from "./content/linux";
import windows from "./content/windows";
import gcp from "./content/gcp";
import otlpTraces from "./content/otlpTraces";
/** Given per-org substitutions, returns a data source's setup-card content. */
export type DataSourceCardBuilder = (
@@ -54,6 +59,11 @@ const registry: Record<string, DataSourceCardBuilder> = {
cassandra,
databricks,
dynamoDB: dynamodb,
kubernetes,
linux,
windows,
gcp,
otlpTraces,
};
/** Whether a data source slug has an in-repo rich setup card. */
@@ -35,8 +35,13 @@ export interface CardSubstitutions {
/** Context of a step → drives both the title chip and the code-block chrome. */
export type StepChipKind = "terminal" | "editor" | "run" | "traces";
/** How a step is marked complete: the user copies its code, or a span lands. */
export type StepCompleteOn = "copy" | "detect";
/**
* How a step is marked complete: the user copies its code, a span lands, or the
* user triggers the step's action button (see RichCardStepAction — used by the
* cloud-provider cards, whose steps launch a console wizard rather than
* producing a command to copy).
*/
export type StepCompleteOn = "copy" | "detect" | "action";
export interface RichCardChip {
kind: StepChipKind;
@@ -78,6 +83,24 @@ export interface RichCardStepVariant {
note?: string;
}
/**
* A button that performs the step instead of handing over a command — e.g.
* "Launch CloudFormation Stack", which opens the AWS console. The renderer is
* presentational, so it only emits `step-action` with this id; the hosting page
* owns the behaviour.
*/
export interface RichCardStepAction {
/** Emitted with the `step-action` event so the page can dispatch. */
id: string;
label: string;
/** OIcon registry name rendered before the label. */
icon?: string;
/** Renders as a secondary button (default is primary). */
variant?: "primary" | "secondary";
/** Greys the button out — e.g. nothing selected yet. */
disabled?: boolean;
}
export interface RichCardStep {
/** Stable id (also used as the scroll target for the next-step auto-advance). */
id: string;
@@ -113,6 +136,11 @@ export interface RichCardStep {
note?: string;
/** Monospace pills rendered after the description (e.g. captured attributes). */
pills?: string[];
/**
* Button that performs this step (cloud-console flows). Rendered after the
* step's own content and any `#step-<id>` slot.
*/
action?: RichCardStepAction;
completeOn: StepCompleteOn;
/** Marks the step as required (renders a "Required" emphasis on its chip). */
required?: boolean;
@@ -157,6 +185,19 @@ export interface RichCardExtras {
fixBody?: string;
/** Highlight language for the fix snippet (e.g. "bash"). Default "python". */
fixLang?: string;
/**
* An alternative, manual path to the same result — e.g. the raw Helm sequence
* behind Kubernetes' one-line installer. Rendered as a collapsed section below
* the steps, so the primary path stays the obvious default. Its code goes
* through the same live substitution as the steps ({stream} and step inputs).
*/
advanced?: {
/** Accordion label, e.g. "Advanced Installation (Manual Steps)". */
label: string;
/** Optional paragraph above the code (inline markdown: **bold**, `code`). */
description?: string;
code: RichCardCode;
};
troubleshooting?: { q: string; a: string }[];
}
@@ -224,7 +265,15 @@ export interface RichCardContent {
/** When set, the card shows a stream-name input (see RichCardStreamInput). */
streamInput?: RichCardStreamInput;
extras?: RichCardExtras;
/** Primary "Full integration docs" link in the footer. */
docUrl?: string;
/**
* Additional reference links rendered as real anchors beside `docUrl` — for
* sources that legitimately have more than one guide (e.g. GCP's Pub/Sub and
* Google Workspace pages). Without this they end up as unclickable text
* inside a collapsed accordion, which is how they get lost.
*/
docLinks?: { label: string; url: string }[];
slackUrl?: string;
}
@@ -13,540 +13,141 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import OpenTelemetry from "@/components/ingestion/traces/OpenTelemetry.vue";
import { nextTick } from "vue";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
// This component backs TWO routes (Recommended > Traces and Custom > Traces >
// OpenTelemetry), so the card it renders is shared by both.
// Mock services and utilities
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { ref } from "vue";
import OpenTelemetry from "./OpenTelemetry.vue";
import { getDataSourceCard } from "@/components/ingestion/setupCard/registry";
const mockConfig = { isCloud: "false" };
vi.mock("@/aws-exports", () => ({
default: {
isCloud: "false",
enableAnalytics: "true"
}
get default() {
return mockConfig;
},
}));
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
getImageURL: vi.fn((path: string) => `/mocked/path/${path}`),
getIngestionURL: vi.fn(() => "http://localhost:5080"),
getEndPoint: vi.fn((url: string) => ({
url: "http://localhost:5080",
host: "localhost:5080",
port: "5080",
protocol: "http",
tls: false
}))
};
const mockEndpoint = ref({
url: "https://test.openobserve.ai",
host: "test.openobserve.ai",
port: 443,
protocol: "https",
tls: true,
});
vi.mock("@/composables/useIngestion", () => ({
default: vi.fn(() => ({ endpoint: mockEndpoint })),
}));
describe("OpenTelemetry Component", () => {
let wrapper: any = null;
vi.mock("@/components/ingestion/setupCard/SetupCardRenderer.vue", () => ({
default: {
name: "SetupCardRenderer",
props: ["content", "subs", "logoUrl", "logoUrlDark"],
template: '<div data-test="rich-card-stub" />',
},
}));
const mockProps = {
currOrgIdentifier: "test-org",
currUserEmail: "test@example.com"
};
const mockStore = createStore({
state: {
selectedOrganization: { identifier: "test-org", name: "Test Organization" },
userInfo: { email: "test@example.com" },
organizationData: { organizationPasscode: "test-passcode" },
theme: "light",
},
});
const mockI18n = createI18n({ locale: "en", messages: { en: {} } });
const SUBS = {
url: "https://test.openobserve.ai",
org: "test-org",
token: "dGVzdEB0b2tlbg==",
};
const buildCard = async () =>
(await import("@/components/ingestion/setupCard/content/otlpTraces")).default(
SUBS,
);
describe("otlpTracesCard builder", () => {
beforeEach(() => {
wrapper = mount(OpenTelemetry, {
props: mockProps,
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
// Reset mocks
vi.clearAllMocks();
mockConfig.isCloud = "false";
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
it("builds the OTLP card metadata and step flow", async () => {
const card = await buildCard();
expect(card.provider.metaBadges).toEqual(["Traces"]);
expect(card.steps.map((s) => s.id)).toEqual(["configure", "verify"]);
expect(card.detect).toMatchObject({
streamType: "traces",
filter: "trace_id IS NOT NULL",
});
});
it("offers HTTP, gRPC and SDK transports when self-hosted", async () => {
expect((await buildCard()).steps[0].variants?.map((v) => v.id)).toEqual([
"http",
"grpc",
"sdk",
]);
});
it("drops gRPC on cloud, which terminates HTTP only", async () => {
mockConfig.isCloud = "true";
vi.resetModules();
expect((await buildCard()).steps[0].variants?.map((v) => v.id)).toEqual([
"http",
"sdk",
]);
});
it("substitutes endpoint, org and token into every transport", async () => {
for (const v of (await buildCard()).steps[0].variants!) {
expect(v.code.raw).toContain(SUBS.org);
expect(v.code.raw).toContain(SUBS.token);
expect(v.code.raw).not.toContain("[BASIC_PASSCODE]");
expect(v.code.masked).not.toContain(SUBS.token);
}
});
// Component Initialization Tests
describe("Component Initialization", () => {
it("should mount OpenTelemetry component", () => {
expect(wrapper).toBeTruthy();
});
it("should initialize with correct component name", () => {
expect(wrapper.vm.$options.name).toBe("traces-otlp");
});
it("should initialize with correct props", () => {
expect(wrapper.exists()).toBe(true);
expect(wrapper.exists()).toBe(true);
});
it("should initialize store instance", () => {
expect(wrapper.vm.store).toBeTruthy();
expect(wrapper.vm.store.state).toBeTruthy();
});
it("should initialize config object", () => {
expect(wrapper.vm.config).toBeTruthy();
expect(typeof wrapper.vm.config).toBe('object');
});
it("should initialize getImageURL function", () => {
expect(wrapper.vm.getImageURL).toBeTruthy();
expect(typeof wrapper.vm.getImageURL).toBe('function');
});
it("should have CopyContent component registered", () => {
expect(wrapper.vm.$options.components.CopyContent).toBeTruthy();
});
it("keeps the stream name in lockstep between config and detection", async () => {
const card = await buildCard();
expect(card.streamInput?.default).toBe("default");
// Every transport writes the stream the check below watches.
for (const v of card.steps[0].variants!) {
expect(v.code.raw).toContain("{stream}");
}
});
// Endpoint Configuration Tests
describe("Endpoint Configuration", () => {
it("should initialize endpoint object with correct structure", () => {
expect(wrapper.vm.endpoint).toBeTruthy();
expect(wrapper.vm.endpoint).toHaveProperty('url');
expect(wrapper.vm.endpoint).toHaveProperty('host');
expect(wrapper.vm.endpoint).toHaveProperty('port');
expect(wrapper.vm.endpoint).toHaveProperty('protocol');
expect(wrapper.vm.endpoint).toHaveProperty('tls');
});
it("derives the gRPC tls.insecure flag from the endpoint scheme", async () => {
const grpc = (await buildCard()).steps[0].variants!.find(
(v) => v.id === "grpc",
)!;
// HTTPS endpoint → insecure must be false, else the handshake fails.
expect(grpc.code.raw).toContain("insecure: false");
expect(grpc.code.raw).toContain("test.openobserve.ai:5081");
});
});
it("should set correct endpoint URL", () => {
expect(wrapper.vm.endpoint.url).toBe("http://localhost:5080");
});
describe("OpenTelemetry.vue", () => {
let wrapper: VueWrapper<any>;
it("should set correct endpoint host", () => {
expect(wrapper.vm.endpoint.host).toBe("localhost:5080");
});
it("should set correct endpoint port", () => {
expect(wrapper.vm.endpoint.port).toBe("5080");
});
it("should set correct endpoint protocol", () => {
expect(wrapper.vm.endpoint.protocol).toBe("http");
});
it("should set correct endpoint TLS setting", () => {
expect(wrapper.vm.endpoint.tls).toBe(false);
});
it("should call getIngestionURL utility function", () => {
// Test that the ingestionURL value is set correctly
expect(wrapper.vm.ingestionURL).toBe("http://localhost:5080");
});
it("should call getEndPoint utility function", () => {
// Test that the endpoint is properly configured
expect(wrapper.vm.endpoint.url).toBe("http://localhost:5080");
expect(wrapper.vm.endpoint.host).toBe("localhost:5080");
});
it("should handle ingestionURL properly", () => {
expect(wrapper.vm.ingestionURL).toBe("http://localhost:5080");
});
afterEach(() => {
if (wrapper) wrapper.unmount();
});
// HTTP Traces Content Tests
describe("HTTP Traces Content Generation", () => {
it("should generate correct HTTP traces URL", () => {
const expectedURL = `http://localhost:5080/api/default`;
expect(wrapper.vm.copyHTTPTracesContentURL).toBe(expectedURL);
});
it("should generate correct HTTP traces passcode", () => {
const expectedPasscode = `Basic [BASIC_PASSCODE]`;
expect(wrapper.vm.copyHTTPTracesContentPasscode).toBe(expectedPasscode);
});
it("should use organization identifier from store in URL", () => {
expect(wrapper.vm.copyHTTPTracesContentURL).toContain("default");
});
it("should handle different organization identifiers", () => {
// Test with different store state
const testWrapper = mount(OpenTelemetry, {
props: mockProps,
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
// The URL should contain the organization identifier from the store
expect(testWrapper.vm.copyHTTPTracesContentURL).toMatch(/\/api\/[^\/]+$/);
testWrapper.unmount();
});
it("should construct HTTP URL with correct format", () => {
const url = wrapper.vm.copyHTTPTracesContentURL;
expect(url).toMatch(/^https?:\/\/.*\/api\/.*$/);
});
it("should use Basic authentication format", () => {
expect(wrapper.vm.copyHTTPTracesContentPasscode).toMatch(/^Basic \[BASIC_PASSCODE\]$/);
it("renders the shared setup card for the otlpTraces slug", () => {
expect(getDataSourceCard("otlpTraces", SUBS)?.provider.name).toContain(
"Traces",
);
wrapper = mount(OpenTelemetry, {
global: { plugins: [mockStore, mockI18n] },
});
expect(wrapper.findComponent({ name: "SetupCardRenderer" }).exists()).toBe(
true,
);
});
// gRPC Content Tests
describe("gRPC Traces Content Generation", () => {
it("should generate correct gRPC content structure", () => {
const content = wrapper.vm.copyGRPCTracesContent;
expect(content).toContain("endpoint:");
expect(content).toContain("headers:");
expect(content).toContain("Authorization:");
expect(content).toContain("organization:");
expect(content).toContain("stream-name:");
expect(content).toContain("tls:");
expect(content).toContain("insecure:");
});
it("should include correct endpoint host in gRPC content", () => {
expect(wrapper.vm.copyGRPCTracesContent).toContain("endpoint: localhost:5080");
});
it("should include correct authorization in gRPC content", () => {
expect(wrapper.vm.copyGRPCTracesContent).toContain('Authorization: "Basic [BASIC_PASSCODE]"');
});
it("should include correct organization in gRPC content", () => {
expect(wrapper.vm.copyGRPCTracesContent).toContain("organization: default");
});
it("should include default stream name in gRPC content", () => {
expect(wrapper.vm.copyGRPCTracesContent).toContain("stream-name: default");
});
it("should set insecure to true when protocol is http", () => {
expect(wrapper.vm.copyGRPCTracesContent).toContain("insecure: true");
});
it("should handle HTTPS protocol correctly", () => {
// Create a wrapper with HTTPS endpoint
const httpsWrapper = mount(OpenTelemetry, {
props: mockProps,
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
// Mock the endpoint to be HTTPS
httpsWrapper.vm.endpoint.protocol = "https";
// Regenerate the gRPC content
const httpsGRPCContent = `endpoint: ${httpsWrapper.vm.endpoint.host}
headers:
Authorization: "Basic [BASIC_PASSCODE]"
organization: ${httpsWrapper.vm.store.state.selectedOrganization.identifier}
stream-name: default
tls:
insecure: ${httpsWrapper.vm.endpoint.protocol == "https" ? false : true}`;
expect(httpsGRPCContent).toContain("insecure: false");
httpsWrapper.unmount();
});
it("should generate multi-line gRPC content", () => {
const lines = wrapper.vm.copyGRPCTracesContent.split('\n');
expect(lines.length).toBeGreaterThan(5);
});
it("should use organization identifier from store in gRPC content", () => {
expect(wrapper.vm.copyGRPCTracesContent).toContain("organization: default");
});
});
// Template Rendering Tests
describe("Template Rendering", () => {
it("should render OTLP HTTP title", () => {
const httpTitle = wrapper.find('[data-test="vector-title-text"]');
expect(httpTitle.exists()).toBe(true);
expect(httpTitle.text()).toContain("OTLP HTTP");
});
it("should render two CopyContent components for HTTP", () => {
// Since CopyContent is stubbed, test that the component has the structure
expect(wrapper.html()).toBeTruthy();
// Test that the component has the expected content values
expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
expect(wrapper.vm.copyHTTPTracesContentPasscode).toBeTruthy();
});
it("should pass correct HTTP URL to first CopyContent", () => {
const copyComponents = wrapper.findAll('CopyContent-stub');
if (copyComponents.length > 0) {
const firstCopy = copyComponents[0];
expect(firstCopy.attributes('content')).toBe(wrapper.vm.copyHTTPTracesContentURL);
} else {
// Test that content is generated correctly even if component is stubbed
expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
}
});
it("should pass correct HTTP passcode to second CopyContent", () => {
const copyComponents = wrapper.findAll('CopyContent-stub');
if (copyComponents.length > 1) {
const secondCopy = copyComponents[1];
expect(secondCopy.attributes('content')).toBe(wrapper.vm.copyHTTPTracesContentPasscode);
} else {
// Test that content is generated correctly even if component is stubbed
expect(wrapper.vm.copyHTTPTracesContentPasscode).toBeTruthy();
}
});
it("should render OTLP gRPC section", () => {
const titleElements = wrapper.findAll('[data-test="vector-title-text"]');
expect(titleElements.length).toBe(2);
expect(titleElements[1].text()).toContain("OTLP gRPC");
});
it("should pass gRPC content to third CopyContent", () => {
const copyComponents = wrapper.findAll('CopyContent-stub');
if (copyComponents.length > 2) {
const thirdCopy = copyComponents[2];
expect(thirdCopy.attributes('content')).toBe(wrapper.vm.copyGRPCTracesContent);
} else {
// Test that content is generated correctly even if component is stubbed
expect(wrapper.vm.copyGRPCTracesContent).toBeTruthy();
}
});
it("should render proper display content for HTTP endpoint", () => {
const copyComponents = wrapper.findAll('CopyContent-stub');
if (copyComponents.length > 0) {
const firstCopy = copyComponents[0];
expect(firstCopy.attributes('displaycontent')).toContain('HTTP Endpoint:');
expect(firstCopy.attributes('displaycontent')).toContain(wrapper.vm.copyHTTPTracesContentURL);
} else {
// Test that content is generated correctly
expect(wrapper.vm.copyHTTPTracesContentURL).toContain('http');
}
});
it("should render proper display content for authorization", () => {
const copyComponents = wrapper.findAll('CopyContent-stub');
if (copyComponents.length > 1) {
const secondCopy = copyComponents[1];
expect(secondCopy.attributes('displaycontent')).toContain('Authorization:');
expect(secondCopy.attributes('displaycontent')).toContain(wrapper.vm.copyHTTPTracesContentPasscode);
} else {
// Test that content is generated correctly
expect(wrapper.vm.copyHTTPTracesContentPasscode).toContain('Basic');
}
});
});
// Props Handling Tests
describe("Props Handling", () => {
it("should handle currOrgIdentifier prop", () => {
expect(wrapper.props().currOrgIdentifier).toBe("test-org");
});
it("should handle currUserEmail prop", () => {
expect(wrapper.props().currUserEmail).toBe("test@example.com");
});
it("should handle undefined currOrgIdentifier", () => {
const noPropsWrapper = mount(OpenTelemetry, {
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
expect(noPropsWrapper.props().currOrgIdentifier).toBeUndefined();
noPropsWrapper.unmount();
});
it("should handle undefined currUserEmail", () => {
const noPropsWrapper = mount(OpenTelemetry, {
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
expect(noPropsWrapper.props().currUserEmail).toBeUndefined();
noPropsWrapper.unmount();
});
it("should accept string type for currOrgIdentifier", () => {
const testWrapper = mount(OpenTelemetry, {
props: { currOrgIdentifier: "different-org" },
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
expect(testWrapper.props().currOrgIdentifier).toBe("different-org");
testWrapper.unmount();
});
it("should accept string type for currUserEmail", () => {
const testWrapper = mount(OpenTelemetry, {
props: { currUserEmail: "different@email.com" },
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
expect(testWrapper.props().currUserEmail).toBe("different@email.com");
testWrapper.unmount();
});
});
// Utility Functions Integration Tests
describe("Utility Functions Integration", () => {
it("should call getImageURL function", () => {
const result = wrapper.vm.getImageURL("test-path");
expect(result).toBe("/mocked/path/test-path");
});
it("should have access to getImageURL in component", () => {
expect(typeof wrapper.vm.getImageURL).toBe("function");
});
it("should integrate with getIngestionURL", () => {
// Test that the function produces the expected result
expect(wrapper.vm.ingestionURL).toBeTruthy();
expect(typeof wrapper.vm.ingestionURL).toBe('string');
});
it("should integrate with getEndPoint", () => {
// Test that the endpoint object is properly structured
expect(wrapper.vm.endpoint).toHaveProperty('url');
expect(wrapper.vm.endpoint).toHaveProperty('host');
expect(wrapper.vm.endpoint).toHaveProperty('port');
expect(wrapper.vm.endpoint).toHaveProperty('protocol');
});
});
// Edge Cases and Error Handling Tests
describe("Edge Cases and Error Handling", () => {
it("should handle empty organization identifier", () => {
// Test with a mock store that has empty organization identifier
const emptyOrgWrapper = mount(OpenTelemetry, {
props: mockProps,
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
expect(emptyOrgWrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
expect(emptyOrgWrapper.vm.copyGRPCTracesContent).toBeTruthy();
emptyOrgWrapper.unmount();
});
it("should handle different protocol schemes", () => {
// The component should handle both http and https
expect(wrapper.vm.endpoint.protocol).toBe("http");
expect(wrapper.vm.copyGRPCTracesContent).toContain("insecure: true");
});
it("should handle null endpoint values gracefully", () => {
// Test that the component doesn't crash with null values
const nullEndpoint = {
url: null,
host: null,
port: null,
protocol: null,
tls: null
};
// Component should still render without crashing
expect(wrapper.vm.endpoint).toBeTruthy();
});
it("should handle special characters in organization identifier", () => {
// The component should handle organization identifiers with special characters
const url = wrapper.vm.copyHTTPTracesContentURL;
expect(url).toBeTruthy();
expect(typeof url).toBe('string');
});
it("should maintain content consistency", () => {
// HTTP and gRPC content should use the same organization identifier
const httpOrg = wrapper.vm.copyHTTPTracesContentURL.split('/').pop();
const gRPCOrg = wrapper.vm.copyGRPCTracesContent.match(/organization: (.*)/)[1];
expect(httpOrg).toBe(gRPCOrg);
});
it("should handle component unmounting", () => {
expect(() => {
wrapper.unmount();
wrapper = mount(OpenTelemetry, {
props: mockProps,
global: {
plugins: [store, i18n],
stubs: {
"CopyContent": true
}
}
});
}).not.toThrow();
});
it("should handle reactive data updates", async () => {
const initialURL = wrapper.vm.copyHTTPTracesContentURL;
// Simulate endpoint change
wrapper.vm.endpoint.url = "https://new-host:443";
await nextTick();
// The URL should still be accessible
expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
});
});
// Integration Tests
describe("Component Integration", () => {
it("should integrate properly with Vuex store", () => {
expect(wrapper.vm.store.state.selectedOrganization).toBeTruthy();
expect(wrapper.vm.store.state.selectedOrganization.identifier).toBe("default");
});
it("should maintain data consistency across all content types", () => {
const orgId = wrapper.vm.store.state.selectedOrganization.identifier;
expect(wrapper.vm.copyHTTPTracesContentURL).toContain(orgId);
expect(wrapper.vm.copyGRPCTracesContent).toContain(orgId);
});
it("should handle store state changes gracefully", async () => {
// Verify initial state
expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
expect(wrapper.vm.copyGRPCTracesContent).toBeTruthy();
// Content should remain valid
expect(wrapper.vm.copyHTTPTracesContentURL.length).toBeGreaterThan(0);
expect(wrapper.vm.copyGRPCTracesContent.length).toBeGreaterThan(0);
});
});
});
});
@@ -14,78 +14,24 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<IngestionContent>
<div class="flex flex-col gap-2" data-test="vector-title-text">
<b>OTLP HTTP</b>
<CopyContent
:content="copyHTTPTracesContentURL"
:displayContent="'HTTP Endpoint: ' + copyHTTPTracesContentURL"
/>
<CopyContent
:content="copyHTTPTracesContentPasscode"
:displayContent="'Authorization: ' + copyHTTPTracesContentPasscode"
/>
</div>
<!--
OTLP traces setup page. Content lives in setupCard/content/otlpTraces.ts and
renders through the shared DataSourceSetupCard.
<div class="flex flex-col gap-2" data-test="vector-title-text" v-if="config.isCloud == 'false'">
<b>OTLP gRPC</b>
<CopyContent :content="copyGRPCTracesContent" />
</div>
</IngestionContent>
Mounted by TWO routes (see useIngestionRoutes): Recommended > Traces
(`ingestFromTraces`) and Custom > Traces > OpenTelemetry
(`ingestTracesFromOtel`) — they show the same content, so both follow this
component.
-->
<template>
<DataSourceSetupCard slug="otlpTraces" />
</template>
<script lang="ts">
import { defineComponent, ref, type Ref } from "vue";
import config from "../../../aws-exports";
import { useStore } from "vuex";
import { getEndPoint, getImageURL, getIngestionURL } from "../../../utils/zincutils";
import CopyContent from "@/components/CopyContent.vue";
import IngestionContent from "@/components/ingestion/IngestionContent.vue";
<script setup lang="ts">
import DataSourceSetupCard from "@/components/ingestion/setupCard/DataSourceSetupCard.vue";
export default defineComponent({
name: "traces-otlp",
props: {
currOrgIdentifier: {
type: String,
},
currUserEmail: {
type: String,
},
},
components: { CopyContent, IngestionContent },
setup(props) {
const store = useStore();
const endpoint: any = ref({
url: "",
host: "",
port: "",
protocol: "",
tls: "",
});
const ingestionURL = getIngestionURL();
endpoint.value = getEndPoint(ingestionURL);
const copyHTTPTracesContentURL = `${endpoint.value.url}/api/${store.state.selectedOrganization.identifier}`;
const copyHTTPTracesContentPasscode = `Basic [BASIC_PASSCODE]`;
const copyGRPCTracesContent = `endpoint: ${endpoint.value.host}
headers:
Authorization: "Basic [BASIC_PASSCODE]"
organization: ${store.state.selectedOrganization.identifier}
stream-name: default
tls:
insecure: ${endpoint.value.protocol == "https" ? false : true}`;
return {
store,
config,
endpoint,
copyHTTPTracesContentURL,
copyHTTPTracesContentPasscode,
copyGRPCTracesContent,
getImageURL,
ingestionURL,
};
},
});
defineProps<{
currOrgIdentifier?: string;
currUserEmail?: string;
}>();
</script>
+34
View File
@@ -2417,6 +2417,40 @@
"iis": "IIS",
"pubsub": "Pub/Sub Logs",
"aiLabel": "AI Integrations",
"azureSetup": {
"activityLogsTitle": "Azure Activity Logs",
"activityLogsDescription": "Stream Azure subscription activity logs to OpenObserve via Event Hub. The ARM template sets up the Event Hub infrastructure \u2014 you then configure Azure to export logs to it.",
"manualTitle": "Manual Configuration (for reference)",
"searchPlaceholder": "Search Azure services...",
"categoryAll": "All Services",
"categoryLogs": "Logs",
"categoryCompute": "Compute",
"categoryStorage": "Storage",
"categorySecurity": "Security",
"categoryNetworking": "Networking",
"noResults": "No integrations found matching your search"
},
"awsSetup": {
"title": "AWS Integrations",
"description": "Set up AWS monitoring in one click or configure individual services for granular control.",
"quickSetup": "Quick Setup",
"individualServices": "Individual Services",
"manualTitle": "Manual Configuration",
"manualDescription": "Use these credentials for custom AWS integrations or manual setup.",
"searchPlaceholder": "Search AWS services...",
"categoryAll": "All Services",
"categoryLogs": "Logs",
"categoryMetrics": "Metrics",
"categorySecurity": "Security",
"categoryNetworking": "Networking",
"noResults": "No integrations found matching your search",
"viewDocumentation": "View Documentation",
"addSource": "Add Source",
"documentation": "Documentation",
"addDashboard": "Add Dashboard",
"chooseMethod": "Choose Integration Method",
"chooseMethodPrompt": "Select how you want to integrate {service}:"
},
"mcp": {
"name": "OpenObserve MCP Server",
"shortName": "MCP Server",
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Guards the base locale file against a silent failure mode: JSON.parse accepts
// duplicate keys and keeps the LAST one. Adding a nested block whose name
// collides with an existing flat label silently replaces that label with an
// object, and every t() call for it then renders the raw key path on screen.
//
// That is exactly what happened when an `"azure": { … }` block was added
// alongside the existing `"azure": "Microsoft Azure"` label used by the Data
// Sources sidebar tab and the global search index.
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const LOCALE = resolve(__dirname, "./languages/en-US.json");
/**
* Returns the names of keys that appear more than once within the same object.
* Walks the raw text because the duplicates are already gone by the time
* JSON.parse returns.
*/
export function findDuplicateKeys(text: string): string[] {
const dupes: string[] = [];
// One set of seen key names per open object.
const scopes: Array<Set<string>> = [];
let pendingKey: string | null = null;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === '"') {
// Read a complete string token, honouring escapes.
let value = "";
i++;
while (i < text.length && text[i] !== '"') {
if (text[i] === "\\") {
value += text[i] + text[i + 1];
i += 2;
continue;
}
value += text[i];
i++;
}
i++; // closing quote
pendingKey = value;
continue;
}
if (ch === "{") {
scopes.push(new Set());
pendingKey = null;
} else if (ch === "}") {
scopes.pop();
pendingKey = null;
} else if (ch === ":") {
// The string just read was a key in the innermost object.
const scope = scopes[scopes.length - 1];
if (scope && pendingKey !== null) {
if (scope.has(pendingKey)) dupes.push(pendingKey);
scope.add(pendingKey);
}
pendingKey = null;
} else if (ch === "," || ch === "[" || ch === "]") {
pendingKey = null;
}
i++;
}
return [...new Set(dupes)];
}
describe("findDuplicateKeys", () => {
it("detects a duplicate key within the same object", () => {
// The exact shape of the bug this guard exists for.
const bad = '{"ingestion":{"azure":"Microsoft Azure","azure":{"title":"x"}}}';
expect(findDuplicateKeys(bad)).toEqual(["azure"]);
});
it("does not flag the same name used in different objects", () => {
const ok = '{"a":{"title":"x"},"b":{"title":"y"}}';
expect(findDuplicateKeys(ok)).toEqual([]);
});
it("is not confused by braces or colons inside string values", () => {
const ok = '{"a":"{not: a key}","b":"plain"}';
expect(findDuplicateKeys(ok)).toEqual([]);
});
});
describe("en-US locale file", () => {
const raw = readFileSync(LOCALE, "utf8");
it("parses as valid JSON", () => {
expect(() => JSON.parse(raw)).not.toThrow();
});
it("has no duplicate keys within any object", () => {
expect(findDuplicateKeys(raw)).toEqual([]);
});
it("keeps the Data Sources sidebar labels as strings", () => {
// Rendered directly by Recommended.vue and the Ingestion search index — if
// any becomes an object, that tab shows its raw key path instead of a name.
const { ingestion } = JSON.parse(raw);
for (const key of [
"azure",
"kubernetes",
"windows",
"linux",
"awsconfig",
"gcpconfig",
"tracesotlp",
"rum",
]) {
expect(typeof ingestion[key], `ingestion.${key}`).toBe("string");
expect(ingestion[key].length).toBeGreaterThan(0);
}
});
});