mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
fix: datasources UI fix (#13286)
This commit is contained in:
@@ -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://<helm-release-name>-openobserve-router.<namespace>.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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user