From f8a64ccfcb5df3ae2f6606c134cf2e3ce9e87d11 Mon Sep 17 00:00:00 2001 From: Chaitanya Sistla <122406662+chaitanya-sistla@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:00:38 -0500 Subject: [PATCH] Feat: Add cloudformation stack-set support (#11268) --- web/src/components/CopyContent.vue | 4 + .../recommended/AWSQuickSetup.spec.ts | 261 ++++--- .../ingestion/recommended/AWSQuickSetup.vue | 731 +++++++++++------- .../ingestion/recommended/AzureConfig.spec.ts | 93 ++- .../ingestion/recommended/AzureConfig.vue | 282 ++++++- .../recommended/AzureIndividualServices.vue | 1 + .../recommended/AzureIntegrationTile.vue | 71 +- web/src/utils/awsIntegrations.ts | 57 +- web/src/utils/azureIntegrations.ts | 33 + 9 files changed, 1084 insertions(+), 449 deletions(-) diff --git a/web/src/components/CopyContent.vue b/web/src/components/CopyContent.vue index 96f16fa7be..e4a5f86d4c 100644 --- a/web/src/components/CopyContent.vue +++ b/web/src/components/CopyContent.vue @@ -126,6 +126,10 @@ export default defineComponent({ }, }, watch: { + content(newVal: string) { + this.displayData = newVal; + this.refreshData(); + }, computedData() { this.refreshData(); }, diff --git a/web/src/components/ingestion/recommended/AWSQuickSetup.spec.ts b/web/src/components/ingestion/recommended/AWSQuickSetup.spec.ts index 86f43633fa..1c5d46dec9 100644 --- a/web/src/components/ingestion/recommended/AWSQuickSetup.spec.ts +++ b/web/src/components/ingestion/recommended/AWSQuickSetup.spec.ts @@ -1,11 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mount, VueWrapper } from '@vue/test-utils'; -import { Quasar } from 'quasar'; +import { Quasar, Notify } from 'quasar'; import { createStore } from 'vuex'; import { createI18n } from 'vue-i18n'; import { createRouter, createWebHistory } from 'vue-router'; import AWSQuickSetup from './AWSQuickSetup.vue'; +const { MOCK_SERVICES, MOCK_REGIONS } = vi.hoisted(() => ({ + MOCK_SERVICES: [ + { label: 'CloudTrail', flag: 'EnableCloudTrail' }, + { label: 'CloudWatch Metrics', flag: 'EnableCloudWatchMetrics' }, + { label: 'VPC Flow Logs', flag: 'EnableVPCFlowLogs' }, + ], + MOCK_REGIONS: [ + { value: 'us-east-1', label: 'US East (N. Virginia)' }, + { value: 'us-west-2', label: 'US West (Oregon)' }, + { value: 'eu-west-1', label: 'Europe (Ireland)' }, + ], +})); + vi.mock('../../../aws-exports', () => ({ default: { API_ENDPOINT: 'http://localhost:5080', region: 'us-east-1' }, })); @@ -22,36 +35,10 @@ vi.mock('@/utils/zincutils', () => ({ })); 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 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, - }, - ], + awsIntegrations: [], generateCloudFormationURL: vi.fn(() => 'https://console.aws.amazon.com/cloudformation'), - awsIntegrationCategories: { logs: 'Logs', networking: 'Networking' }, + AWS_REGIONS: MOCK_REGIONS, + QUICK_SETUP_SERVICES: MOCK_SERVICES, })); vi.mock('@/services/segment_analytics', () => ({ @@ -77,7 +64,6 @@ describe('AWSQuickSetup.vue', () => { beforeEach(() => { vi.clearAllMocks(); - // Mock window.open vi.stubGlobal('open', vi.fn()); }); @@ -86,14 +72,13 @@ describe('AWSQuickSetup.vue', () => { vi.unstubAllGlobals(); }); - const createWrapper = () => { - return mount(AWSQuickSetup, { + const createWrapper = () => + mount(AWSQuickSetup, { global: { - plugins: [Quasar, mockI18n, mockRouter], + plugins: [[Quasar, { plugins: { Notify } }], mockI18n, mockRouter], provide: { store: mockStore }, }, }); - }; describe('Component Rendering', () => { it('should mount without errors', () => { @@ -106,137 +91,115 @@ describe('AWSQuickSetup.vue', () => { expect(() => wrapper.unmount()).not.toThrow(); }); - it('should render Deploy Complete Stack button', () => { + it('should have correct component name', () => { wrapper = createWrapper(); - const deployBtn = wrapper.find('[data-test="aws-quick-setup-deploy-btn"]'); - expect(deployBtn.exists()).toBe(true); - }); - - it('should render View/Hide Details button', () => { - wrapper = createWrapper(); - const detailsBtn = wrapper.find('[data-test="aws-quick-setup-details-btn"]'); - expect(detailsBtn.exists()).toBe(true); + expect(wrapper.vm.$options.name).toBe('AWSQuickSetup'); }); it('should render Complete AWS Integration title', () => { wrapper = createWrapper(); expect(wrapper.text()).toContain('Complete AWS Integration'); }); - }); - describe('Component Identity', () => { - it('should have correct component name', () => { + it('should render deployment mode toggle', () => { wrapper = createWrapper(); - expect(wrapper.vm.$options.name).toBe('AWSQuickSetup'); + expect(wrapper.find('[data-test="aws-deployment-mode-toggle"]').exists()).toBe(true); + }); + + it('should render launch button', () => { + wrapper = createWrapper(); + expect(wrapper.find('[data-test="aws-quick-setup-deploy-btn"]').exists()).toBe(true); }); }); - describe('showDetails Toggle', () => { - it('should have showDetails false by default', () => { + describe('Deployment Mode', () => { + it('should default to single region mode', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.showDetails).toBe(false); + expect((wrapper.vm as any).deploymentMode).toBe('single'); }); - it('should toggle showDetails when details button is clicked', async () => { + it('should show region select in single mode', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.showDetails).toBe(false); - const detailsBtn = wrapper.find('[data-test="aws-quick-setup-details-btn"]'); - await detailsBtn.trigger('click'); - expect(vm.showDetails).toBe(true); + expect(wrapper.find('[data-test="aws-region-select"]').exists()).toBe(true); }); - it('should toggle showDetails back when clicked again', async () => { + it('should default selected region to us-east-1', () => { + wrapper = createWrapper(); + expect((wrapper.vm as any).selectedRegion).toBe('us-east-1'); + }); + + it('should switch to stackset mode', async () => { wrapper = createWrapper(); const vm = wrapper.vm as any; - const detailsBtn = wrapper.find('[data-test="aws-quick-setup-details-btn"]'); - await detailsBtn.trigger('click'); - expect(vm.showDetails).toBe(true); - await detailsBtn.trigger('click'); - expect(vm.showDetails).toBe(false); + vm.deploymentMode = 'stackset'; + await wrapper.vm.$nextTick(); + expect(vm.deploymentMode).toBe('stackset'); }); }); - describe('includedServices Computed', () => { - it('should compute includedServices with services having CloudFormation templates', () => { + describe('Services Selection', () => { + it('should start with all services enabled', () => { wrapper = createWrapper(); const vm = wrapper.vm as any; - expect(vm.includedServices).toBeDefined(); - expect(Array.isArray(vm.includedServices)).toBe(true); + expect(vm.enabledServices.length).toBe(MOCK_SERVICES.length); }); - it('should include services with cloudFormationTemplate', () => { + it('should deselect all services', () => { wrapper = createWrapper(); const vm = wrapper.vm as any; - // CloudWatch Logs and S3 Events have cloudFormationTemplate - expect(vm.includedServices.length).toBeGreaterThan(0); + vm.deselectAll(); + expect(vm.enabledServices.length).toBe(0); }); - it('should sort services by name', () => { + it('should select all services', () => { wrapper = createWrapper(); const vm = wrapper.vm as any; - const names = vm.includedServices.map((s: any) => s.name); - const sortedNames = [...names].sort(); - expect(names).toEqual(sortedNames); + vm.deselectAll(); + vm.selectAll(); + expect(vm.enabledServices.length).toBe(MOCK_SERVICES.length); }); - it('should include service with name and description', () => { + it('should start with services section collapsed', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - if (vm.includedServices.length > 0) { - expect(vm.includedServices[0].name).toBeDefined(); - expect(vm.includedServices[0].description).toBeDefined(); - expect(vm.includedServices[0].category).toBeDefined(); - } + expect((wrapper.vm as any).showServices).toBe(false); }); }); - describe('getCategoryIcon Function', () => { - it('should return icon for logs category', () => { + describe('StackSets Mode', () => { + it('should default stackset model to self-managed', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.getCategoryIcon('logs')).toBe('description'); + expect((wrapper.vm as any).stackSetModel).toBe('self'); }); - it('should return icon for metrics category', () => { + it('should default target regions to us-east-1', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.getCategoryIcon('metrics')).toBe('speed'); + expect((wrapper.vm as any).targetRegions).toEqual(['us-east-1']); }); - it('should return icon for security category', () => { + it('should start with target regions collapsed', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.getCategoryIcon('security')).toBe('security'); + expect((wrapper.vm as any).showTargetRegions).toBe(false); }); - it('should return cloud icon for unknown category', () => { + it('should select all regions', () => { wrapper = createWrapper(); const vm = wrapper.vm as any; - expect(vm.getCategoryIcon('unknown')).toBe('cloud'); + vm.selectAllRegions(); + expect(vm.targetRegions.length).toBe(MOCK_REGIONS.length); + }); + + it('should clear target regions', () => { + wrapper = createWrapper(); + const vm = wrapper.vm as any; + vm.targetRegions = []; + expect(vm.targetRegions.length).toBe(0); }); }); - describe('formatCategory Function', () => { - it('should capitalize first letter', () => { - wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.formatCategory('logs')).toBe('Logs'); - }); - - it('should capitalize security', () => { - wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(vm.formatCategory('security')).toBe('Security'); - }); - }); - - describe('handleDeployStack', () => { + describe('handleLaunch - Single Region', () => { it('should be exposed as a function', () => { wrapper = createWrapper(); - const vm = wrapper.vm as any; - expect(typeof vm.handleDeployStack).toBe('function'); + expect(typeof (wrapper.vm as any).handleLaunch).toBe('function'); }); it('should call window.open when endpoint is valid', async () => { @@ -245,5 +208,81 @@ describe('AWSQuickSetup.vue', () => { await deployBtn.trigger('click'); expect(window.open).toHaveBeenCalled(); }); + + it('should not call window.open when no services selected', async () => { + wrapper = createWrapper(); + const vm = wrapper.vm as any; + vm.deselectAll(); + await wrapper.vm.$nextTick(); + const deployBtn = wrapper.find('[data-test="aws-quick-setup-deploy-btn"]'); + await deployBtn.trigger('click'); + expect(window.open).not.toHaveBeenCalled(); + }); + }); + + describe('handleLaunch - StackSets', () => { + it('should open stacksets console URL in stackset mode', async () => { + wrapper = createWrapper(); + const vm = wrapper.vm as any; + vm.deploymentMode = 'stackset'; + await wrapper.vm.$nextTick(); + vm.handleLaunch(); + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining('stacksets/create'), + '_blank', + 'noopener,noreferrer', + ); + }); + + it('should show param helper after stackset launch', async () => { + wrapper = createWrapper(); + const vm = wrapper.vm as any; + vm.deploymentMode = 'stackset'; + await wrapper.vm.$nextTick(); + vm.handleLaunch(); + expect(vm.showParamHelper).toBe(true); + }); + + it('should not launch stackset when no target regions selected', async () => { + wrapper = createWrapper(); + const vm = wrapper.vm as any; + vm.deploymentMode = 'stackset'; + vm.targetRegions = []; + await wrapper.vm.$nextTick(); + vm.handleLaunch(); + expect(window.open).not.toHaveBeenCalled(); + }); + }); + + describe('stackSetParams computed', () => { + it('should include template URL as first param', () => { + wrapper = createWrapper(); + const vm = wrapper.vm as any; + const params = vm.stackSetParams; + expect(params[0].key).toBe('Amazon S3 URL (template)'); + expect(params[0].value).toContain('aws_complete.yaml'); + }); + + it('should include TemplateS3Bucket param', () => { + wrapper = createWrapper(); + const params = (wrapper.vm as any).stackSetParams; + const bucket = params.find((p: any) => p.key === 'TemplateS3Bucket'); + expect(bucket?.value).toBe('openobserve-datasources-bucket'); + }); + + it('should include OpenObserveEndpoint param', () => { + wrapper = createWrapper(); + const params = (wrapper.vm as any).stackSetParams; + const ep = params.find((p: any) => p.key === 'OpenObserveEndpoint'); + expect(ep?.value).toContain('localhost'); + }); + + it('should include a param for each service flag', () => { + wrapper = createWrapper(); + const params = (wrapper.vm as any).stackSetParams; + MOCK_SERVICES.forEach(({ flag }) => { + expect(params.find((p: any) => p.key === flag)).toBeDefined(); + }); + }); }); }); diff --git a/web/src/components/ingestion/recommended/AWSQuickSetup.vue b/web/src/components/ingestion/recommended/AWSQuickSetup.vue index 2dfd798013..1abea1def4 100644 --- a/web/src/components/ingestion/recommended/AWSQuickSetup.vue +++ b/web/src/components/ingestion/recommended/AWSQuickSetup.vue @@ -17,117 +17,256 @@ along with this program. If not, see . @@ -137,143 +276,170 @@ import { defineComponent, ref, computed } from "vue"; import { useStore } from "vuex"; import { useQuasar } from "quasar"; import { getEndPoint, getIngestionURL } from "@/utils/zincutils"; -import { generateCloudFormationURL, awsIntegrations } from "@/utils/awsIntegrations"; +import { generateCloudFormationURL, AWS_REGIONS, QUICK_SETUP_SERVICES } from "@/utils/awsIntegrations"; import segment from "@/services/segment_analytics"; +const COMPLETE_TEMPLATE_URL = 'https://openobserve-datasources-bucket.s3.us-east-2.amazonaws.com/datasource/cloud/aws/aws_complete.yaml'; + export default defineComponent({ name: "AWSQuickSetup", setup() { const store = useStore(); const q = useQuasar(); - const showDetails = ref(false); - // Get endpoint information during setup + const deploymentMode = ref<'single' | 'stackset'>('single'); + const stackSetModel = ref<'self' | 'service'>('self'); + const selectedRegion = ref('us-east-1'); + const targetRegions = ref(['us-east-1']); + const enabledServices = ref(QUICK_SETUP_SERVICES.map(s => s.flag)); + const showParamHelper = ref(false); + const showTargetRegions = ref(false); + const showServices = ref(false); + let endpoint: any = null; try { - const ingestionURL = getIngestionURL(); - endpoint = getEndPoint(ingestionURL); + endpoint = getEndPoint(getIngestionURL()); } catch (e) { - console.error("Error getting endpoint during setup:", e); + console.error("Error getting endpoint:", e); } - // Get all services with CloudFormation templates - const includedServices = computed(() => { - return awsIntegrations - .filter(integration => - integration.cloudFormationTemplate || - (integration.cloudFormationTemplates && integration.cloudFormationTemplates.length > 0) - ) - .map(integration => ({ - name: integration.displayName, - description: integration.description, - category: integration.category, - })) - .sort((a, b) => a.name.localeCompare(b.name)); + const getCredentials = () => { + const organizationId = store.state?.selectedOrganization?.identifier; + const email = store.state?.userInfo?.email; + const passcode = store.state?.organizationData?.organizationPasscode; + return { organizationId, email, passcode }; + }; + + const selectAll = () => { enabledServices.value = QUICK_SETUP_SERVICES.map(s => s.flag); }; + const deselectAll = () => { enabledServices.value = []; }; + const selectAllRegions = () => { targetRegions.value = AWS_REGIONS.map(r => r.value); }; + + const buildServiceFlags = () => { + const flags: Record = {}; + QUICK_SETUP_SERVICES.forEach(({ flag }) => { + flags[flag] = enabledServices.value.includes(flag) ? 'true' : 'false'; + }); + return flags; + }; + + // Parameters the user will need to copy-paste in the StackSets wizard + const stackSetParams = computed(() => { + const { organizationId, email, passcode } = getCredentials(); + if (!organizationId || !email || !passcode || !endpoint?.url) return []; + + const accessKey = btoa(`${email}:${passcode}`); + const endpointUrl = `${endpoint.url}/aws/${organizationId}/default/_kinesis_firehose`; + const serviceFlags = buildServiceFlags(); + + const params = [ + { key: 'Amazon S3 URL (template)', value: COMPLETE_TEMPLATE_URL }, + { key: 'TemplateS3Bucket', value: 'openobserve-datasources-bucket' }, + { key: 'TemplateS3Prefix', value: 'datasource/cloud/aws' }, + { key: 'OpenObserveEndpoint', value: endpointUrl }, + { key: 'OpenObserveAccessKey', value: accessKey }, + ...QUICK_SETUP_SERVICES.map(({ flag }) => ({ key: flag, value: serviceFlags[flag] })), + ]; + return params; }); - const getCategoryIcon = (category: string) => { - const iconMap: Record = { - 'logs': 'description', - 'metrics': 'speed', - 'security': 'security', - 'networking': 'network_check', - 'other': 'settings', - }; - return iconMap[category] || 'cloud'; + const copyParam = (value: string) => { + navigator.clipboard.writeText(value); + q.notify({ type: 'positive', message: 'Copied to clipboard', timeout: 1500 }); }; - const formatCategory = (category: string) => { - return category.charAt(0).toUpperCase() + category.slice(1); - }; + const handleLaunch = () => { + if (!endpoint?.url) { + q.notify({ type: "negative", message: "Invalid ingestion endpoint. Please check configuration.", timeout: 3000 }); + return; + } - const handleDeployStack = () => { - try { - // Validate endpoint - if (!endpoint?.url) { - console.error("Invalid endpoint:", endpoint); - q.notify({ - type: "negative", - message: "Invalid ingestion endpoint. Please check configuration.", - timeout: 3000, - }); + const { organizationId, email, passcode } = getCredentials(); + if (!organizationId || !email || !passcode) { + q.notify({ type: "negative", message: "Missing organization credentials. Please refresh the page.", timeout: 3000 }); + return; + } + + if (deploymentMode.value === 'single') { + launchSingleRegion(organizationId, email, passcode); + } else { + if (targetRegions.value.length === 0) { + q.notify({ type: "warning", message: "Select at least one target region.", timeout: 3000 }); return; } - - // Get organization details - const organizationId = store.state?.selectedOrganization?.identifier; - const email = store.state?.userInfo?.email; - const passcode = store.state?.organizationData?.organizationPasscode; - - // Validate required data - if (!organizationId || !email || !passcode) { - console.error("Missing required data:", { organizationId, email, hasPasscode: !!passcode }); - q.notify({ - type: "negative", - message: "Missing organization credentials. Please refresh the page.", - timeout: 3000, - }); - return; - } - - // Generate base64 encoded access key - const accessKey = btoa(`${email}:${passcode}`); - - // Create a unified integration object - const unifiedIntegration = { - id: 'aws-complete', - name: 'AWS-Complete', - displayName: 'AWS Complete Integration', - cloudFormationTemplate: 'https://openobserve-datasources-bucket.s3.us-east-2.amazonaws.com/datasource/cloud/aws/aws_complete.yaml', - comingSoon: false, - }; - - // Generate CloudFormation URL - const cloudFormationURL = generateCloudFormationURL( - unifiedIntegration as any, - organizationId, - `${endpoint.url}/aws/${organizationId}/default/_kinesis_firehose`, - accessKey - ); - - if (!cloudFormationURL) { - q.notify({ - type: "warning", - message: "CloudFormation template not available yet", - timeout: 3000, - }); - return; - } - - // Open AWS Console in new tab - window.open(cloudFormationURL, '_blank', 'noopener,noreferrer'); - - // Track analytics - segment.track("AWS Complete Integration Started", { - integration_type: "complete", - services_count: includedServices.value.length, - }); - - q.notify({ - type: "info", - message: "Opening AWS Console to deploy complete integration stack", - timeout: 3000, - }); - } catch (error) { - console.error("Error deploying stack:", error); - q.notify({ - type: "negative", - message: `Error opening AWS Console: ${error instanceof Error ? error.message : 'Unknown error'}`, - timeout: 5000, - }); + launchStackSet(organizationId); } }; + const launchSingleRegion = (organizationId: string, email: string, passcode: string) => { + const accessKey = btoa(`${email}:${passcode}`); + + const url = generateCloudFormationURL( + { + id: 'aws-complete', + name: 'AWS-Complete', + displayName: 'AWS Complete Integration', + cloudFormationTemplate: COMPLETE_TEMPLATE_URL, + comingSoon: false, + } as any, + organizationId, + `${endpoint.url}/aws/${organizationId}/default/_kinesis_firehose`, + accessKey, + selectedRegion.value, + { + TemplateS3Bucket: 'openobserve-datasources-bucket', + TemplateS3Prefix: 'datasource/cloud/aws', + ...buildServiceFlags(), + } + ); + + if (!url) { + q.notify({ type: "warning", message: "CloudFormation template not available yet", timeout: 3000 }); + return; + } + + window.open(url, '_blank', 'noopener,noreferrer'); + segment.track("AWS Complete Integration Started", { mode: 'single', region: selectedRegion.value, services: enabledServices.value }); + q.notify({ type: "info", message: "Opening AWS Console to deploy complete integration stack", timeout: 3000 }); + }; + + const launchStackSet = (organizationId: string) => { + // StackSets console doesn't support URL pre-fill — open the console and show param helper + const consoleUrl = `https://console.aws.amazon.com/cloudformation/home?region=${selectedRegion.value}#/stacksets/create`; + window.open(consoleUrl, '_blank', 'noopener,noreferrer'); + showParamHelper.value = true; + + segment.track("AWS StackSet Integration Started", { + mode: 'stackset', + model: stackSetModel.value, + admin_region: selectedRegion.value, + target_regions: targetRegions.value, + services: enabledServices.value, + }); + + q.notify({ + type: "info", + message: "AWS StackSets console opened. Use the parameter values below to complete setup.", + timeout: 5000, + }); + }; + return { - showDetails, - includedServices, - handleDeployStack, - getCategoryIcon, - formatCategory, + deploymentMode, + stackSetModel, + selectedRegion, + targetRegions, + enabledServices, + showParamHelper, + showTargetRegions, + showServices, + stackSetParams, + AWS_REGIONS, + QUICK_SETUP_SERVICES, + selectAll, + deselectAll, + selectAllRegions, + copyParam, + handleLaunch, }; }, }); @@ -286,96 +452,75 @@ export default defineComponent({ margin: 0 auto; } - .service-item { - transition: all 0.2s ease; + .step-label { + font-weight: 600; + font-size: 0.9rem; + } + + .param-helper { + border-radius: 8px; + padding: 16px; + } + + .param-table { + display: flex; + flex-direction: column; + gap: 6px; + } + + .param-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 10px; + border-radius: 4px; + font-size: 0.8rem; + font-family: monospace; + } + + .param-key { + min-width: 240px; + font-weight: 600; + flex-shrink: 0; + } + + .param-value { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + overflow: hidden; + } + + .param-val-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; } .body--light & { - .title { - color: #1a1a1a; - } - - .description, - .benefit-text, - .detail-value { - color: #666; - } - - .service-name { - color: #1a1a1a; - } - - .service-description { - color: #666; - } - - .service-category { - color: #999; - font-weight: 500; - } - - .service-item { - border: 1px solid #e0e0e0; - background-color: #fafafa; - - &:hover { - background-color: #f5f5f5; - } - } - - .section-header, - .detail-label, - .details-title { - color: #333; - } - - .notice-banner { - background-color: #e3f2fd; - } + .title { color: #1a1a1a; } + .description, .detail-value, .mode-hint { color: #666; } + .step-label { color: #333; } + .region-hint { color: #888; } + .param-helper { background: #f5f5f5; } + .param-row { background: #fff; border: 1px solid #e0e0e0; } + .param-key { color: #333; } + .param-val-text { color: #555; } + .region-collapsible-header { background: #f0f4ff; border: 1px solid #d0d9f0; &:hover { background: #e8eeff; } } } .body--dark & { - .title { - color: #e0e0e0; - } - - .description, - .benefit-text, - .detail-value { - color: #b0b0b0; - } - - .service-name { - color: #e0e0e0; - } - - .service-description { - color: #b0b0b0; - } - - .service-category { - color: #808080; - font-weight: 500; - } - - .service-item { - border: 1px solid #404040; - background-color: rgba(255, 255, 255, 0.05); - - &:hover { - background-color: rgba(255, 255, 255, 0.08); - } - } - - .section-header, - .detail-label, - .details-title { - color: #d0d0d0; - } - - .notice-banner { - background-color: rgba(33, 150, 243, 0.1); - } + .title { color: #e0e0e0; } + .description, .detail-value, .mode-hint { color: #b0b0b0; } + .step-label { color: #d0d0d0; } + .region-hint { color: #888; } + .param-helper { background: rgba(255,255,255,0.05); } + .param-row { background: rgba(255,255,255,0.03); border: 1px solid #404040; } + .param-key { color: #ccc; } + .param-val-text { color: #aaa; } + .region-collapsible-header { background: rgba(255,255,255,0.06); border: 1px solid #404040; &:hover { background: rgba(255,255,255,0.09); } } } } diff --git a/web/src/components/ingestion/recommended/AzureConfig.spec.ts b/web/src/components/ingestion/recommended/AzureConfig.spec.ts index 42a273095d..fb644ac976 100644 --- a/web/src/components/ingestion/recommended/AzureConfig.spec.ts +++ b/web/src/components/ingestion/recommended/AzureConfig.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mount, VueWrapper } from '@vue/test-utils'; -import { Quasar } from 'quasar'; +import { Quasar, Notify } from 'quasar'; import { createStore } from 'vuex'; import { createI18n } from 'vue-i18n'; import { createRouter, createWebHistory } from 'vue-router'; @@ -14,37 +14,30 @@ 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)), + maskText: vi.fn((str) => '***'), })); vi.mock('../../../utils/azureIntegrations', () => ({ azureIntegrations: [ - { id: 'azure-ad', name: 'AzureAD', displayName: 'Azure Active Directory', description: 'Azure AD logs', category: 'security', hasDashboard: false }, - { id: 'azure-storage', name: 'AzureStorage', displayName: 'Azure Storage', description: 'Azure Storage logs', category: 'storage', hasDashboard: true }, + { + id: 'activity-logs', + name: 'Activity Logs', + displayName: 'Azure Activity Logs', + description: 'Stream Azure subscription activity logs', + armTemplate: 'https://mock-s3-bucket.s3.amazonaws.com/activity-logs.json', + hasDashboard: false, + category: 'logs', + }, ], + generateARMTemplateURL: vi.fn(() => 'https://portal.azure.com/#create/Microsoft.Template/uri/mock'), generateAzureDashboardURL: vi.fn(() => 'https://mock-dashboard-url'), })); -vi.mock('@/services/dashboards', () => ({ - default: { - list_Folders: vi.fn(() => Promise.resolve({ data: { list: [] } })), - new_Folder: vi.fn(() => Promise.resolve({ data: { folderId: 'folder-1', name: 'Microsoft' } })), - list: vi.fn(() => Promise.resolve({ data: { dashboards: [] } })), - create: vi.fn(() => Promise.resolve({ data: {} })), - delete: vi.fn(() => Promise.resolve({ data: {} })), - }, -})); - vi.mock('@/services/segment_analytics', () => ({ default: { track: vi.fn() }, })); -vi.mock('./AzureQuickSetup.vue', () => ({ - default: { - name: 'AzureQuickSetup', - template: '
Azure Quick Setup
', - }, -})); - const mockStore = createStore({ state: { selectedOrganization: { identifier: 'test-org', name: 'Test Organization' }, @@ -64,20 +57,21 @@ describe('AzureConfig.vue', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal('open', vi.fn()); }); afterEach(() => { if (wrapper) wrapper.unmount(); + vi.unstubAllGlobals(); }); - const createWrapper = () => { - return mount(AzureConfig, { + const createWrapper = () => + mount(AzureConfig, { global: { - plugins: [Quasar, mockI18n, mockRouter], + plugins: [[Quasar, { plugins: { Notify } }], mockI18n, mockRouter], provide: { store: mockStore }, }, }); - }; describe('Component Rendering', () => { it('should mount without errors', () => { @@ -90,16 +84,20 @@ describe('AzureConfig.vue', () => { expect(() => wrapper.unmount()).not.toThrow(); }); - it('should render AzureQuickSetup child component', () => { - wrapper = createWrapper(); - const child = wrapper.find('[data-test="azure-quick-setup"]'); - expect(child.exists()).toBe(true); - }); - it('should have azure-config-page class', () => { wrapper = createWrapper(); expect(wrapper.find('.azure-config-page').exists()).toBe(true); }); + + it('should render Azure Activity Logs title', () => { + wrapper = createWrapper(); + expect(wrapper.text()).toContain('Azure Activity Logs'); + }); + + it('should render deploy button', () => { + wrapper = createWrapper(); + expect(wrapper.find('[data-test="azure-activity-logs-deploy-btn"]').exists()).toBe(true); + }); }); describe('Component Identity', () => { @@ -114,4 +112,39 @@ describe('AzureConfig.vue', () => { expect(typeof wrapper.vm).toBe('object'); }); }); + + describe('Step 2 Mode Toggle', () => { + it('should default to portal mode', () => { + wrapper = createWrapper(); + expect((wrapper.vm as any).step2Mode).toBe('portal'); + }); + + it('should switch to cli mode', async () => { + wrapper = createWrapper(); + (wrapper.vm as any).step2Mode = 'cli'; + await wrapper.vm.$nextTick(); + expect((wrapper.vm as any).step2Mode).toBe('cli'); + }); + }); + + describe('Category Selection', () => { + it('should start with all categories enabled', () => { + wrapper = createWrapper(); + expect((wrapper.vm as any).enabledCategories.length).toBe(8); + }); + + it('should clear categories', () => { + wrapper = createWrapper(); + (wrapper.vm as any).enabledCategories = []; + expect((wrapper.vm as any).enabledCategories.length).toBe(0); + }); + }); + + describe('handleDeploy', () => { + it('should call window.open when credentials are valid', () => { + wrapper = createWrapper(); + (wrapper.vm as any).handleDeploy(); + expect(window.open).toHaveBeenCalled(); + }); + }); }); diff --git a/web/src/components/ingestion/recommended/AzureConfig.vue b/web/src/components/ingestion/recommended/AzureConfig.vue index 5a20206849..d13cec7120 100644 --- a/web/src/components/ingestion/recommended/AzureConfig.vue +++ b/web/src/components/ingestion/recommended/AzureConfig.vue @@ -16,34 +16,294 @@ along with this program. If not, see . diff --git a/web/src/components/ingestion/recommended/AzureIndividualServices.vue b/web/src/components/ingestion/recommended/AzureIndividualServices.vue index a8912f034b..c5dc3129c7 100644 --- a/web/src/components/ingestion/recommended/AzureIndividualServices.vue +++ b/web/src/components/ingestion/recommended/AzureIndividualServices.vue @@ -43,6 +43,7 @@ along with this program. If not, see . data-test="azure-integration-category-tabs" > + diff --git a/web/src/components/ingestion/recommended/AzureIntegrationTile.vue b/web/src/components/ingestion/recommended/AzureIntegrationTile.vue index 38f750bb75..4ed5b4433b 100644 --- a/web/src/components/ingestion/recommended/AzureIntegrationTile.vue +++ b/web/src/components/ingestion/recommended/AzureIntegrationTile.vue @@ -46,9 +46,19 @@ along with this program. If not, see . - + + + . class="tw:flex-1" :data-test="`azure-${integration.id}-documentation-btn`" /> + + + View Documentation + { + if (!props.integration.armTemplate) return; + + if (!endpoint?.url) { + q.notify({ type: "negative", message: "Invalid ingestion endpoint. Please check configuration.", timeout: 3000 }); + return; + } + + const organizationId = store.state?.selectedOrganization?.identifier; + const email = store.state?.userInfo?.email; + const passcode = store.state?.organizationData?.organizationPasscode; + + if (!organizationId || !email || !passcode) { + q.notify({ type: "negative", message: "Missing organization credentials. Please refresh the page.", timeout: 3000 }); + return; + } + + const accessKey = btoa(`${email}:${passcode}`); + const endpointUrl = `${endpoint.url}/azure/${organizationId}/default/_event_hub`; + + const url = generateARMTemplateURL(props.integration, endpointUrl, accessKey); + + window.open(url, '_blank', 'noopener,noreferrer'); + + segment.track("Azure ARM Template Deploy Started", { + service: props.integration.name, + integration_id: props.integration.id, + }); + + q.notify({ type: "info", message: `Opening Azure portal to deploy ${props.integration.displayName}`, timeout: 3000 }); + }; + const handleDashboard = () => { if (!props.integration.hasDashboard) { return; @@ -146,6 +210,7 @@ export default defineComponent({ }; return { + handleDeploy, handleDashboard, handleDocumentation, }; diff --git a/web/src/utils/awsIntegrations.ts b/web/src/utils/awsIntegrations.ts index 2f5b62951c..462b73e87b 100644 --- a/web/src/utils/awsIntegrations.ts +++ b/web/src/utils/awsIntegrations.ts @@ -345,6 +345,59 @@ export const awsIntegrations: AWSIntegration[] = [ }, ]; +export const AWS_REGIONS = [ + { value: 'us-east-1', label: 'US East (N. Virginia)' }, + { value: 'us-east-2', label: 'US East (Ohio)' }, + { value: 'us-west-1', label: 'US West (N. California)' }, + { value: 'us-west-2', label: 'US West (Oregon)' }, + { value: 'ca-central-1', label: 'Canada (Central)' }, + { value: 'ca-west-1', label: 'Canada West (Calgary)' }, + { value: 'eu-west-1', label: 'Europe (Ireland)' }, + { value: 'eu-west-2', label: 'Europe (London)' }, + { value: 'eu-west-3', label: 'Europe (Paris)' }, + { value: 'eu-central-1', label: 'Europe (Frankfurt)' }, + { value: 'eu-central-2', label: 'Europe (Zurich)' }, + { value: 'eu-north-1', label: 'Europe (Stockholm)' }, + { value: 'eu-south-1', label: 'Europe (Milan)' }, + { value: 'eu-south-2', label: 'Europe (Spain)' }, + { value: 'ap-east-1', label: 'Asia Pacific (Hong Kong)' }, + { value: 'ap-south-1', label: 'Asia Pacific (Mumbai)' }, + { value: 'ap-south-2', label: 'Asia Pacific (Hyderabad)' }, + { value: 'ap-southeast-1', label: 'Asia Pacific (Singapore)' }, + { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney)' }, + { value: 'ap-southeast-3', label: 'Asia Pacific (Jakarta)' }, + { value: 'ap-southeast-4', label: 'Asia Pacific (Melbourne)' }, + { value: 'ap-northeast-1', label: 'Asia Pacific (Tokyo)' }, + { value: 'ap-northeast-2', label: 'Asia Pacific (Seoul)' }, + { value: 'ap-northeast-3', label: 'Asia Pacific (Osaka)' }, + { value: 'sa-east-1', label: 'South America (São Paulo)' }, + { value: 'me-south-1', label: 'Middle East (Bahrain)' }, + { value: 'me-central-1', label: 'Middle East (UAE)' }, + { value: 'il-central-1', label: 'Israel (Tel Aviv)' }, + { value: 'af-south-1', label: 'Africa (Cape Town)' }, + { value: 'us-gov-east-1', label: 'AWS GovCloud (US-East)' }, + { value: 'us-gov-west-1', label: 'AWS GovCloud (US-West)' }, +]; + +export const QUICK_SETUP_SERVICES: { label: string; flag: string }[] = [ + { label: 'CloudTrail', flag: 'EnableCloudTrail' }, + { label: 'CloudWatch Logs', flag: 'EnableCloudWatchLogs' }, + { label: 'CloudWatch Metrics', flag: 'EnableCloudWatchMetrics' }, + { label: 'VPC Flow Logs', flag: 'EnableVPCFlowLogs' }, + { label: 'WAF', flag: 'EnableWAF' }, + { label: 'ALB', flag: 'EnableALB' }, + { label: 'API Gateway', flag: 'EnableApiGateway' }, + { label: 'RDS', flag: 'EnableRDS' }, + { label: 'S3 Access Logs', flag: 'EnableS3AccessLogs' }, + { label: 'CloudFront', flag: 'EnableCloudFront' }, + { label: 'Route53', flag: 'EnableRoute53' }, + { label: 'DynamoDB', flag: 'EnableDynamoDB' }, + { label: 'EC2', flag: 'EnableEC2' }, + { label: 'EventBridge', flag: 'EnableEventBridge' }, + { label: 'Kinesis', flag: 'EnableKinesisStream' }, + { label: 'Cognito', flag: 'EnableCognito' }, +]; + /** * Generate AWS CloudFormation console URL with pre-filled parameters */ @@ -353,12 +406,13 @@ export const generateCloudFormationURL = ( organizationId: string, endpoint: string, accessKey: string, + region: string = 'us-east-1', + extraParams: Record = {}, ): string => { if (!integration.cloudFormationTemplate || integration.comingSoon) { return ''; } - const region = 'us-east-1'; // Default region, can be made configurable const stackName = `o2-${integration.name.replace(/\s+/g, '-')}`; // Encode parameters for URL @@ -372,6 +426,7 @@ export const generateCloudFormationURL = ( param_OrganizationId: organizationId, param_HttpEndpointUrl: endpoint, param_AccessKey: accessKey, + ...Object.fromEntries(Object.entries(extraParams).map(([k, v]) => [`param_${k}`, v])), }); return `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/create/review?${params.toString()}`; diff --git a/web/src/utils/azureIntegrations.ts b/web/src/utils/azureIntegrations.ts index c6baa22d7a..014c9e2111 100644 --- a/web/src/utils/azureIntegrations.ts +++ b/web/src/utils/azureIntegrations.ts @@ -19,6 +19,7 @@ export interface AzureIntegration { displayName: string; icon: string; description: string; + armTemplate?: string; // HTTPS URL to ARM template JSON on S3 hasDashboard: boolean; dashboardFolderId?: string; dashboardGithubUrl?: string; @@ -27,6 +28,17 @@ export interface AzureIntegration { } export const azureIntegrations: AzureIntegration[] = [ + { + id: 'activity-logs', + name: 'Activity Logs', + displayName: 'Azure Activity Logs', + icon: '', + description: 'Stream Azure subscription activity logs to OpenObserve via Event Hub and Event Publisher', + armTemplate: 'https://openobserve-datasources-bucket.s3.us-east-2.amazonaws.com/datasource/cloud/azure/activity-logs-to-openobserve.json', + hasDashboard: false, + documentationUrl: 'https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/activity-log', + category: 'logs', + }, { id: 'aks', name: 'AKS', @@ -189,6 +201,27 @@ export const azureIntegrations: AzureIntegration[] = [ }, ]; +/** + * Generate Azure portal custom deployment URL with pre-filled parameters. + * Uses the ARM template quick-deploy format: + * https://portal.azure.com/#create/Microsoft.Template/uri/{encoded-template-url}/parameters/{encoded-params} + */ +export const generateARMTemplateURL = ( + integration: AzureIntegration, + endpoint: string, + accessKey: string, +): string => { + if (!integration.armTemplate) return ''; + + const templateUri = encodeURIComponent(integration.armTemplate); + const parameters = encodeURIComponent(JSON.stringify({ + openObserveEndpoint: { value: endpoint }, + openObserveAccessKey: { value: accessKey }, + })); + + return `https://portal.azure.com/#create/Microsoft.Template/uri/${templateUri}/parameters/${parameters}`; +}; + /** * Generate dashboard URL for the current environment */