From 661fd6febf700f6b89be5df7a87001a6eb45fe57 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 1 Jul 2025 09:50:44 +0200 Subject: [PATCH 1/5] feat: bulk impact metrics (#10251) --- .../client-metrics/metrics-service-v2.ts | 10 ++- .../metrics/impact/impact-metrics.e2e.test.ts | 63 ++++++++++++++++++- src/lib/features/metrics/instance/metrics.ts | 13 +++- src/lib/openapi/spec/bulk-metrics-schema.ts | 10 +++ src/lib/openapi/spec/impact-metrics-schema.ts | 60 ++++++++++++++++++ src/lib/openapi/spec/index.ts | 1 + 6 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/lib/openapi/spec/impact-metrics-schema.ts diff --git a/src/lib/features/metrics/client-metrics/metrics-service-v2.ts b/src/lib/features/metrics/client-metrics/metrics-service-v2.ts index d2e2da83f2..d089f00604 100644 --- a/src/lib/features/metrics/client-metrics/metrics-service-v2.ts +++ b/src/lib/features/metrics/client-metrics/metrics-service-v2.ts @@ -196,8 +196,14 @@ export default class ClientMetricsServiceV2 { } async registerImpactMetrics(impactMetrics: Metric[]) { - const value = await impactMetricsSchema.validateAsync(impactMetrics); - this.impactMetricsTranslator.translateMetrics(value); + try { + const value = + await impactMetricsSchema.validateAsync(impactMetrics); + this.impactMetricsTranslator.translateMetrics(value); + } catch (e) { + // impact metrics should not affect other metrics on failure + this.logger.warn(e); + } } async registerClientMetrics( diff --git a/src/lib/features/metrics/impact/impact-metrics.e2e.test.ts b/src/lib/features/metrics/impact/impact-metrics.e2e.test.ts index a1eb68ffb3..b0c4eb569b 100644 --- a/src/lib/features/metrics/impact/impact-metrics.e2e.test.ts +++ b/src/lib/features/metrics/impact/impact-metrics.e2e.test.ts @@ -26,6 +26,20 @@ const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) => }) .expect(status); +const sendBulkMetricsWithImpact = async ( + impactMetrics: Metric[], + status = 202, +) => { + return app.request + .post('/api/client/metrics/bulk') + .send({ + applications: [], + metrics: [], + impactMetrics, + }) + .expect(status); +}; + beforeAll(async () => { db = await dbInit('impact_metrics', getLogger); app = await setupAppWithCustomConfig(db.stores, { @@ -72,7 +86,7 @@ test('should store impact metrics in memory and be able to retrieve them', async ]); await sendImpactMetrics([]); - // missing help + // missing help = no error but value ignored await sendImpactMetrics( [ // @ts-expect-error @@ -87,7 +101,7 @@ test('should store impact metrics in memory and be able to retrieve them', async ], }, ], - 400, + 202, ); const response = await app.request @@ -101,3 +115,48 @@ test('should store impact metrics in memory and be able to retrieve them', async expect(metricsText).toContain('# TYPE labeled_counter counter'); expect(metricsText).toMatch(/labeled_counter{foo="bar"} 15/); }); + +test('should store impact metrics sent via bulk metrics endpoint', async () => { + await sendBulkMetricsWithImpact([ + { + name: 'bulk_counter', + help: 'bulk counter with labels', + type: 'counter', + samples: [ + { + labels: { source: 'bulk' }, + value: 7, + }, + ], + }, + ]); + + await sendBulkMetricsWithImpact([ + { + name: 'bulk_counter', + help: 'bulk counter with labels', + type: 'counter', + samples: [ + { + labels: { source: 'bulk' }, + value: 8, + }, + ], + }, + ]); + + await sendBulkMetricsWithImpact([]); + + const response = await app.request + .get('/internal-backstage/impact/metrics') + .expect('Content-Type', /text/) + .expect(200); + + const metricsText = response.text; + + expect(metricsText).toContain( + '# HELP bulk_counter bulk counter with labels', + ); + expect(metricsText).toContain('# TYPE bulk_counter counter'); + expect(metricsText).toMatch(/bulk_counter{source="bulk"} 15/); +}); diff --git a/src/lib/features/metrics/instance/metrics.ts b/src/lib/features/metrics/instance/metrics.ts index 8eabc96a82..2cc73d502a 100644 --- a/src/lib/features/metrics/instance/metrics.ts +++ b/src/lib/features/metrics/instance/metrics.ts @@ -230,7 +230,7 @@ export default class ClientMetricsController extends Controller { res.status(204).end(); } else { const { body, ip: clientIp } = req; - const { metrics, applications } = body; + const { metrics, applications, impactMetrics } = body; try { const promises: Promise[] = []; for (const app of applications) { @@ -275,6 +275,17 @@ export default class ClientMetricsController extends Controller { ); this.config.eventBus.emit(CLIENT_METRICS, data); } + + if ( + this.flagResolver.isEnabled('impactMetrics') && + impactMetrics && + impactMetrics.length > 0 + ) { + promises.push( + this.metricsV2.registerImpactMetrics(impactMetrics), + ); + } + await Promise.all(promises); res.status(202).end(); diff --git a/src/lib/openapi/spec/bulk-metrics-schema.ts b/src/lib/openapi/spec/bulk-metrics-schema.ts index 998007550a..b17b160221 100644 --- a/src/lib/openapi/spec/bulk-metrics-schema.ts +++ b/src/lib/openapi/spec/bulk-metrics-schema.ts @@ -2,6 +2,7 @@ import type { FromSchema } from 'json-schema-to-ts'; import { bulkRegistrationSchema } from './bulk-registration-schema.js'; import { dateSchema } from './date-schema.js'; import { clientMetricsEnvSchema } from './client-metrics-env-schema.js'; +import { impactMetricsSchema } from './impact-metrics-schema.js'; export const bulkMetricsSchema = { $id: '#/components/schemas/bulkMetricsSchema', @@ -25,12 +26,21 @@ export const bulkMetricsSchema = { $ref: '#/components/schemas/clientMetricsEnvSchema', }, }, + impactMetrics: { + description: + 'a list of custom impact metrics registered by downstream providers. (Typically Unleash Edge)', + type: 'array', + items: { + $ref: '#/components/schemas/impactMetricsSchema', + }, + }, }, components: { schemas: { bulkRegistrationSchema, dateSchema, clientMetricsEnvSchema, + impactMetricsSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/impact-metrics-schema.ts b/src/lib/openapi/spec/impact-metrics-schema.ts new file mode 100644 index 0000000000..b4d8c3df4d --- /dev/null +++ b/src/lib/openapi/spec/impact-metrics-schema.ts @@ -0,0 +1,60 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const impactMetricsSchema = { + $id: '#/components/schemas/impactMetricsSchema', + type: 'object', + required: ['name', 'help', 'type', 'samples'], + description: 'Used for reporting impact metrics from SDKs', + properties: { + name: { + type: 'string', + description: 'Name of the impact metric', + example: 'my-counter', + }, + help: { + description: + 'Human-readable description of what the metric measures', + type: 'string', + example: 'Counts the number of operations', + }, + type: { + description: 'Type of the metric', + type: 'string', + enum: ['counter', 'gauge'], + example: 'counter', + }, + samples: { + description: 'Samples of the metric', + type: 'array', + items: { + type: 'object', + required: ['value'], + description: + 'A sample of a metric with a value and optional labels', + properties: { + value: { + type: 'number', + description: 'The value of the metric sample', + example: 10, + }, + labels: { + description: 'Optional labels for the metric sample', + type: 'object', + additionalProperties: { + type: 'string', + }, + example: { + application: 'my-app', + environment: 'production', + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type ImpactMetricsSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 04771b2f0e..6e44c2ba4b 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -113,6 +113,7 @@ export * from './health-overview-schema.js'; export * from './health-report-schema.js'; export * from './id-schema.js'; export * from './ids-schema.js'; +export * from './impact-metrics-schema.js'; export * from './import-toggles-schema.js'; export * from './import-toggles-validate-item-schema.js'; export * from './import-toggles-validate-schema.js'; From 6503deea9b1d2054dcd06dd571cbd35ddf766f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 1 Jul 2025 09:04:34 +0100 Subject: [PATCH 2/5] chore: AI flag cleanup extended experiment (#10254) Experimenting more with AI flag cleanup. --- .github/workflows/ai-flag-cleanup-pr.yml | 258 +++++++++++++++++++++++ .github/workflows/ai-flag-cleanup.yml | 24 ++- 2 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ai-flag-cleanup-pr.yml diff --git a/.github/workflows/ai-flag-cleanup-pr.yml b/.github/workflows/ai-flag-cleanup-pr.yml new file mode 100644 index 0000000000..a81232707c --- /dev/null +++ b/.github/workflows/ai-flag-cleanup-pr.yml @@ -0,0 +1,258 @@ +name: AI flag cleanup PR + +on: + workflow_call: + inputs: + issue-number: + description: "Flag completed issue number" + required: true + type: number + model: + description: "Model to use" + required: true + type: string + api_key_env_name: + description: "The name of the API key environment variable. For example, OPENAI_API_KEY, ANTHROPIC_API_KEY, etc. See more info: https://aider.chat/docs/llms.html" + required: true + type: string + base-branch: + description: "Base branch to create PR against (e.g. main)" + required: false + type: string + default: ${{ github.event.repository.default_branch }} + chat-timeout: + description: "Timeout for flag cleanup, in minutes" + required: false + type: number + default: 10 + secrets: + api_key_env_value: + description: "The API key" + required: true + +permissions: + pull-requests: write + contents: write + issues: read + +jobs: + create-pull-request: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Create a new branch + uses: actions/github-script@v7 + id: create_branch + with: + script: | + const kebabCase = (str) => { + return str + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove invalid characters + .replace(/\s+/g, '-') // Replace spaces with dashes + .replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes + }; + + const fixBranchUrl = (url) => url + .replace(/\/git\/commits/, '/commit') + .replace(/api.github.com\/repos/, 'github.com'); + + // New branch should be based on the base-branch, so we need to get its SHA + const baseBranch = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: '${{ inputs.base-branch }}' + }); + + const { repo, owner } = context.repo; + const branchName = 'feature/aider-' + kebabCase(context.payload.issue.title); + const refName = `refs/heads/${branchName}` + const refShortName = `heads/${branchName}` + + // Get existing ref if exists + const existingRef = await github.rest.git.getRef({ + owner, + repo, + ref: refShortName + }).catch(() => null); + + if (existingRef) { + try { + // If there's a branch for this ref, return the ref + await github.rest.repos.getBranch({ + owner, + repo, + branch: branchName + }); + + console.log(`Branch ${branchName} already exists with SHA ${existingRef.data.object.sha}`); + console.log(`Branch URL: ${fixBranchUrl(existingRef.data.object.url)}`); + + return { ref: existingRef.data.ref } + } catch (e) { + console.error(e); + // State recovery: If there's a ref but no branch, delete the ref and create a new branch + // This can happen if the branch was deleted manually. The ref will still exist. + console.log(`Branch ${branchName} doesn't exist, deleting ref ${refShortName}`); + await github.rest.git.deleteRef({ + owner, + repo, + ref: refShortName + }); + } + } + + // Create branch + const result = await github.rest.git.createRef({ + owner, + repo, + ref: refName, + sha: baseBranch.data.commit.sha + }); + + console.log(`Created branch ${branchName} with SHA ${result.data.object.sha}`); + console.log(`Branch URL: ${fixBranchUrl(result.data.object.url)}`); + + return { ref: result.data.ref } + + - name: Get issue + uses: actions/github-script@v7 + id: get_issue + with: + script: | + console.log('Fetching issue #${{ inputs.issue-number }}') + const { repo, owner } = context.repo; + const result = await github.rest.issues.get({ + owner, + repo, + issue_number: ${{ inputs.issue-number }} + }); + console.log(`Fetched issue #${result.data.number}: ${result.data.title}`) + + return { + title: result.data.title.replace(/"/g, "'").replace(/`/g, '\\`'), + body: result.data.body.replace(/"/g, "'").replace(/`/g, '\\`'), + }; + + - name: Extract flag name + id: extract_flag + run: | + TITLE="${{ fromJson(steps.get_issue.outputs.result).title }}" + if [[ "$TITLE" =~ Flag[[:space:]]([a-zA-Z0-9_-]+)[[:space:]]marked ]]; then + echo "flag-name=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + else + echo "❌ Could not extract flag name from title: $TITLE" + exit 1 + fi + + - name: Install ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep + + - name: Find files using the flag + id: find_files + run: | + FLAG="${{ steps.extract_flag.outputs.flag-name }}" + FILES=$(rg -0 -l "$FLAG" . | xargs -0 -I{} printf '"%s" ' "{}") + + if [[ -z "$FILES" ]]; then + echo "❌ No files found using flag '$FLAG'." + exit 1 + fi + + echo "file_args=$FILES" >> "$GITHUB_OUTPUT" + + - name: Create prompt + uses: actions/github-script@v7 + id: create_prompt + with: + result-encoding: string + script: | + const body = `${{ fromJson(steps.get_issue.outputs.result).body }}`; + + return `Apply all necessary changes to clean up this feature flag based on the issue description below. + + After making the changes, provide a Markdown summary of what was changed, written for a developer reviewing the PR. These changes should be under a "AI Flag Cleanup Summary" section. + + Explain: + - What was removed + - What was kept + - Why these changes were made + + Write a natural summary in proper Markdown. Keep it clear, focused, and readable. + + --- Issue Description --- + ${body}`; + + - name: Apply cleanup with Aider + uses: mirrajabi/aider-github-action@v1.1.0 + timeout-minutes: ${{ inputs.chat-timeout }} + with: + branch: ${{ fromJson(steps.create_branch.outputs.result).ref }} + model: ${{ inputs.model }} + aider_args: '--yes ${{ steps.find_files.outputs.file_args }} --message "${{ steps.create_prompt.outputs.result }}"' + api_key_env_name: ${{ inputs.api_key_env_name }} + api_key_env_value: ${{ secrets.api_key_env_value }} + + - name: Extract summary from Aider output + id: extract_summary + run: | + SUMMARY=$(awk '/AI Flag Cleanup Summary/ {found=1} found' .aider.chat.history.md) + + echo "summary<> $GITHUB_OUTPUT + echo "$SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Pull Request + uses: actions/github-script@v7 + with: + script: | + const { repo, owner } = context.repo; + const branchRef = '${{ fromJson(steps.create_branch.outputs.result).ref }}' + const flagName = '${{ steps.extract_flag.outputs.flag-name }}'; + + // If PR already exists, return it + const pulls = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100 + }); + + const existingPR = pulls.data.find((pr) => pr.head.ref === branchRef); + if (existingPR) { + console.log(`PR #${existingPR.number} already exists: ${existingPR.html_url}`); + return existingPR; + } + + const newPR = await github.rest.pulls.create({ + title: `[AI] ${flagName} flag cleanup`, + owner, + repo, + head: branchRef, + base: 'refs/heads/${{ inputs.base-branch }}', + body: [ + `This PR cleans up the ${flagName} flag. These changes were automatically generated by AI and should be reviewed carefully.`, + '', + `Fixes #${{ inputs.issue-number }}`, + '', + `${{ steps.extract_summary.outputs.summary }}` + ].join('\n') + }); + github.rest.issues.addLabels({ + owner, + repo, + issue_number: newPR.data.number, + labels: ['unleash-ai-flag-cleanup'] + }); + + console.log(`Created PR #${newPR.data.number}: ${newPR.data.html_url}`); + - name: Upload aider chat history + uses: actions/upload-artifact@v4 + with: + name: aider-chat-output + path: ".aider.chat.history.md" diff --git a/.github/workflows/ai-flag-cleanup.yml b/.github/workflows/ai-flag-cleanup.yml index f5f45e0a04..02bf37d0f0 100644 --- a/.github/workflows/ai-flag-cleanup.yml +++ b/.github/workflows/ai-flag-cleanup.yml @@ -1,22 +1,24 @@ name: AI flag cleanup + on: issues: types: [labeled] - -permissions: - pull-requests: write - contents: write - issues: read + workflow_dispatch: + inputs: + issue-number: + description: 'Flag completed issue number' + required: true + type: number jobs: flag-cleanup: - uses: mirrajabi/aider-github-workflows/.github/workflows/aider-issue-to-pr.yml@v1.0.0 - if: github.event.label.name == 'unleash-flag-completed' + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issues' && github.event.label.name == 'unleash-flag-completed') + uses: ./.github/workflows/ai-flag-cleanup-pr.yml with: - issue-number: ${{ github.event.issue.number }} - base-branch: ${{ github.event.repository.default_branch }} - chat-timeout: 10 - api_key_env_name: GEMINI_API_KEY + issue-number: ${{ github.event.issue.number || inputs.issue-number }} model: gemini + api_key_env_name: GEMINI_API_KEY secrets: api_key_env_value: ${{ secrets.GEMINI_API_KEY }} From d573e04536ee7319262222cc8e99511f1e492fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Tue, 1 Jul 2025 11:08:49 +0200 Subject: [PATCH 3/5] chore: remove examples folder (#10258) --- examples/README.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 examples/README.md diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 861daea3fe..0000000000 --- a/examples/README.md +++ /dev/null @@ -1 +0,0 @@ -This section has been moved to a separate repository: https://github.com/Unleash/unleash-examples From 0e5080fac5a81aa3cd5f2579a0fa77becde92833 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Tue, 1 Jul 2025 12:50:49 +0300 Subject: [PATCH 4/5] feat: start showing CR id in UI and make it clickable (#10259) ![image](https://github.com/user-attachments/assets/72c631aa-0b60-42c1-b546-870962a3e885) --- .../component/events/EventCard/EventCard.tsx | 22 +++++++++++++++++++ frontend/src/interfaces/uiConfig.ts | 1 + src/lib/types/experimental.ts | 7 +++++- src/server-dev.ts | 1 + 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/component/events/EventCard/EventCard.tsx b/frontend/src/component/events/EventCard/EventCard.tsx index a8ef81caa1..99846faa65 100644 --- a/frontend/src/component/events/EventCard/EventCard.tsx +++ b/frontend/src/component/events/EventCard/EventCard.tsx @@ -5,6 +5,7 @@ import { formatDateYMDHMS } from 'utils/formatDate'; import { Link } from 'react-router-dom'; import { styled } from '@mui/material'; import type { EventSchema } from 'openapi'; +import { useUiFlag } from 'hooks/useUiFlag'; interface IEventCardProps { entry: EventSchema; @@ -72,6 +73,7 @@ export const StyledCodeSection = styled('div')(({ theme }) => ({ const EventCard = ({ entry }: IEventCardProps) => { const { locationSettings } = useLocationSettings(); + const eventGroupingEnabled = useUiFlag('eventGrouping'); const createdAtFormatted = formatDateYMDHMS( entry.createdAt, @@ -138,6 +140,26 @@ const EventCard = ({ entry }: IEventCardProps) => { } /> + + + Change request id: + +
+ + {String(entry.data?.changeRequestId)} + +
+ + } + /> ; @@ -291,6 +292,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_IMPACT_METRICS, false, ), + eventGrouping: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_EVENT_GROUPING, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/server-dev.ts b/src/server-dev.ts index ad51d0a923..06ddfe230f 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -58,6 +58,7 @@ process.nextTick(async () => { improvedJsonDiff: true, impactMetrics: true, crDiffView: true, + eventGrouping: true, }, }, authentication: { From 3f073ad457006669dd172fafb18873462ad92602 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:54:25 +0200 Subject: [PATCH 5/5] feat: impact metrics page with multiple charts (#10252) --- .../common/BreadcrumbNav/BreadcrumbNav.tsx | 4 + .../impact-metrics/ChartConfigModal.tsx | 344 ++++++++++++++++++ .../component/impact-metrics/ChartItem.tsx | 211 +++++++++++ .../impact-metrics/ImpactMetrics.tsx | 134 +++++++ .../ImpactMetricsControls.tsx | 66 ++++ .../components/BeginAtZeroToggle.tsx | 22 ++ .../components/LabelsFilter.tsx | 86 +++++ .../components/RangeSelector.tsx | 26 ++ .../components/SeriesSelector.tsx | 55 +++ .../impact-metrics/ImpactMetricsPage.tsx | 22 ++ .../impact-metrics/hooks/useChartData.ts | 0 .../impact-metrics/hooks/useSeriesColor.ts | 0 .../impact-metrics/hooks/useUrlState.ts | 108 ++++++ .../src/component/impact-metrics/types.ts | 12 + .../{insights => }/impact-metrics/utils.ts | 0 frontend/src/component/insights/Insights.tsx | 4 - .../insights/impact-metrics/ImpactMetrics.tsx | 216 ----------- .../impact-metrics/ImpactMetricsControls.tsx | 203 ----------- .../NavigationSidebar/IconRenderer.tsx | 2 + .../NavigationSidebar/NavigationList.tsx | 7 + .../__snapshots__/routes.test.tsx.snap | 11 + frontend/src/component/menu/routes.ts | 12 + 22 files changed, 1122 insertions(+), 423 deletions(-) create mode 100644 frontend/src/component/impact-metrics/ChartConfigModal.tsx create mode 100644 frontend/src/component/impact-metrics/ChartItem.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetrics.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsPage.tsx rename frontend/src/component/{insights => }/impact-metrics/hooks/useChartData.ts (100%) rename frontend/src/component/{insights => }/impact-metrics/hooks/useSeriesColor.ts (100%) create mode 100644 frontend/src/component/impact-metrics/hooks/useUrlState.ts create mode 100644 frontend/src/component/impact-metrics/types.ts rename frontend/src/component/{insights => }/impact-metrics/utils.ts (100%) delete mode 100644 frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx delete mode 100644 frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx index 937dd38814..51b007dae9 100644 --- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -57,6 +57,10 @@ const BreadcrumbNav = () => { return null; } + if (location.pathname === '/impact-metrics') { + return null; + } + if (paths.length === 1 && paths[0] === 'projects-archive') { // It's not possible to use `projects/archive`, because it's :projectId path paths = ['projects', 'archive']; diff --git a/frontend/src/component/impact-metrics/ChartConfigModal.tsx b/frontend/src/component/impact-metrics/ChartConfigModal.tsx new file mode 100644 index 0000000000..0acbefe985 --- /dev/null +++ b/frontend/src/component/impact-metrics/ChartConfigModal.tsx @@ -0,0 +1,344 @@ +import type { FC } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Box, + Typography, + Alert, + styled, +} from '@mui/material'; +import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx'; +import { + LineChart, + NotEnoughData, +} from '../insights/components/LineChart/LineChart.tsx'; +import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; +import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; +import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; +import { fromUnixTime } from 'date-fns'; +import { useChartData } from './hooks/useChartData.ts'; +import type { ChartConfig } from './types.ts'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; + +export const StyledConfigPanel = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + [theme.breakpoints.down('lg')]: { + flex: 'none', + }, + [theme.breakpoints.up('lg')]: { + flex: '0 0 400px', + }, +})); + +export const StyledPreviewPanel = styled(Box)(({ theme }) => ({ + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + [theme.breakpoints.down('lg')]: { + minHeight: '300px', + }, + [theme.breakpoints.up('lg')]: { + minHeight: '400px', + }, +})); + +export interface ChartConfigModalProps { + open: boolean; + onClose: () => void; + onSave: (config: Omit) => void; + initialConfig?: ChartConfig; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; +} + +export const ChartConfigModal: FC = ({ + open, + onClose, + onSave, + initialConfig, + metricSeries, + loading = false, +}) => { + const [title, setTitle] = useState(initialConfig?.title || ''); + const [selectedSeries, setSelectedSeries] = useState( + initialConfig?.selectedSeries || '', + ); + const [selectedRange, setSelectedRange] = useState< + 'hour' | 'day' | 'week' | 'month' + >(initialConfig?.selectedRange || 'day'); + const [beginAtZero, setBeginAtZero] = useState( + initialConfig?.beginAtZero || false, + ); + const [selectedLabels, setSelectedLabels] = useState< + Record + >(initialConfig?.selectedLabels || {}); + + // Data for preview + const { + data: { start, end, series: timeSeriesData }, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData( + selectedSeries + ? { + series: selectedSeries, + range: selectedRange, + labels: + Object.keys(selectedLabels).length > 0 + ? selectedLabels + : undefined, + } + : undefined, + ); + + // Fetch available labels for the currently selected series + const { + data: { labels: currentAvailableLabels }, + } = useImpactMetricsData( + selectedSeries + ? { + series: selectedSeries, + range: selectedRange, + } + : undefined, + ); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const data = useChartData(timeSeriesData); + + const hasError = !!dataError; + const isLoading = dataLoading; + const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; + const notEnoughData = useMemo( + () => + !isLoading && + (!timeSeriesData || + timeSeriesData.length === 0 || + !data.datasets.some((d) => d.data.length > 1)), + [data, isLoading, timeSeriesData], + ); + + const minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = selectedSeries ? ( + + ) : ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + useEffect(() => { + if (open && initialConfig) { + setTitle(initialConfig.title || ''); + setSelectedSeries(initialConfig.selectedSeries); + setSelectedRange(initialConfig.selectedRange); + setBeginAtZero(initialConfig.beginAtZero); + setSelectedLabels(initialConfig.selectedLabels); + } else if (open && !initialConfig) { + setTitle(''); + setSelectedSeries(''); + setSelectedRange('day'); + setBeginAtZero(false); + setSelectedLabels({}); + } + }, [open, initialConfig]); + + const handleSave = () => { + if (!selectedSeries) return; + + onSave({ + title: title || undefined, + selectedSeries, + selectedRange, + beginAtZero, + selectedLabels, + }); + onClose(); + }; + + const handleSeriesChange = (series: string) => { + setSelectedSeries(series); + setSelectedLabels({}); + }; + + const isValid = selectedSeries.length > 0; + + return ( + + + {initialConfig ? 'Edit Chart' : 'Add New Chart'} + + + ({ + display: 'flex', + flexDirection: { xs: 'column', lg: 'row' }, + gap: theme.spacing(3), + pt: theme.spacing(1), + height: '100%', + })} + > + + setTitle(e.target.value)} + fullWidth + variant='outlined' + size='small' + /> + + + + + {/* Preview Panel */} + + + Preview + + + {!selectedSeries && !isLoading ? ( + + Select a metric series to view the preview + + ) : null} + + + {hasError ? ( + + Failed to load impact metrics. Please check + if Prometheus is configured and the feature + flag is enabled. + + ) : null} + + typeof value === + 'number' + ? formatLargeNumbers( + value, + ) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: + timeSeriesData && + timeSeriesData.length > + 1, + position: + 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + } + } + cover={cover} + /> + + + + + + + + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ChartItem.tsx b/frontend/src/component/impact-metrics/ChartItem.tsx new file mode 100644 index 0000000000..0d8f305550 --- /dev/null +++ b/frontend/src/component/impact-metrics/ChartItem.tsx @@ -0,0 +1,211 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { + Box, + Typography, + IconButton, + Alert, + styled, + Paper, +} from '@mui/material'; +import Edit from '@mui/icons-material/Edit'; +import Delete from '@mui/icons-material/Delete'; +import { + LineChart, + NotEnoughData, +} from '../insights/components/LineChart/LineChart.tsx'; +import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; +import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; +import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; +import { fromUnixTime } from 'date-fns'; +import { useChartData } from './hooks/useChartData.ts'; +import type { ChartConfig } from './types.ts'; + +export interface ChartItemProps { + config: ChartConfig; + onEdit: (config: ChartConfig) => void; + onDelete: (id: string) => void; +} + +const getConfigDescription = (config: ChartConfig): string => { + const parts: string[] = []; + + if (config.selectedSeries) { + parts.push(`Series: ${config.selectedSeries}`); + } + + parts.push(`Time range: last ${config.selectedRange}`); + + if (config.beginAtZero) { + parts.push('Begin at zero'); + } + + const labelCount = Object.keys(config.selectedLabels).length; + if (labelCount > 0) { + parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`); + } + + return parts.join(' • '); +}; + +const StyledHeader = styled(Typography)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(2, 3), +})); + +const StyledWidget = styled(Paper)(({ theme }) => ({ + borderRadius: `${theme.shape.borderRadiusLarge}px`, + boxShadow: 'none', + display: 'flex', + flexDirection: 'column', +})); + +export const ChartItem: FC = ({ config, onEdit, onDelete }) => { + const { + data: { start, end, series: timeSeriesData }, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData({ + series: config.selectedSeries, + range: config.selectedRange, + labels: + Object.keys(config.selectedLabels).length > 0 + ? config.selectedLabels + : undefined, + }); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const data = useChartData(timeSeriesData); + + const hasError = !!dataError; + const isLoading = dataLoading; + const shouldShowPlaceholder = isLoading || hasError; + const notEnoughData = useMemo( + () => + !isLoading && + (!timeSeriesData || + timeSeriesData.length === 0 || + !data.datasets.some((d) => d.data.length > 1)), + [data, isLoading, timeSeriesData], + ); + + const minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + return ( + + + + {config.title && ( + + {config.title} + + )} + + {getConfigDescription(config)} + + + + onEdit(config)} sx={{ mr: 1 }}> + + + onDelete(config.id)}> + + + + + + + {hasError ? ( + + Failed to load impact metrics. Please check if + Prometheus is configured and the feature flag is + enabled. + + ) : null} + + typeof value === 'number' + ? formatLargeNumbers( + value, + ) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: + timeSeriesData && + timeSeriesData.length > 1, + position: 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + } + } + cover={cover} + /> + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx new file mode 100644 index 0000000000..a070ad2092 --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -0,0 +1,134 @@ +import type { FC } from 'react'; +import { useMemo, useState } from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import Add from '@mui/icons-material/Add'; +import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; +import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { ChartConfigModal } from './ChartConfigModal.tsx'; +import { ChartItem } from './ChartItem.tsx'; +import { useUrlState } from './hooks/useUrlState.ts'; +import type { ChartConfig } from './types.ts'; + +export const ImpactMetrics: FC = () => { + const [modalOpen, setModalOpen] = useState(false); + const [editingChart, setEditingChart] = useState(); + + const { charts, addChart, updateChart, deleteChart } = useUrlState(); + + const { + metadata, + loading: metadataLoading, + error: metadataError, + } = useImpactMetricsMetadata(); + + const metricSeries = useMemo(() => { + if (!metadata?.series) { + return []; + } + return Object.entries(metadata.series).map(([name, rest]) => ({ + name, + ...rest, + })); + }, [metadata]); + + const handleAddChart = () => { + setEditingChart(undefined); + setModalOpen(true); + }; + + const handleEditChart = (config: ChartConfig) => { + setEditingChart(config); + setModalOpen(true); + }; + + const handleSaveChart = (config: Omit) => { + if (editingChart) { + updateChart(editingChart.id, config); + } else { + addChart(config); + } + setModalOpen(false); + }; + + const hasError = metadataError; + + return ( + <> + + Impact Metrics + + } + actions={ + charts.length > 0 ? ( + + ) : null + } + /> + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + width: '100%', + })} + > + {charts.length === 0 && !metadataLoading && !hasError ? ( + ({ + textAlign: 'center', + py: theme.spacing(8), + })} + > + + No charts configured + + + Add your first impact metrics chart to start + tracking performance + + + + ) : ( + charts.map((config) => ( + + )) + )} + + setModalOpen(false)} + onSave={handleSaveChart} + initialConfig={editingChart} + metricSeries={metricSeries} + loading={metadataLoading} + /> + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx new file mode 100644 index 0000000000..206efe8ca7 --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx @@ -0,0 +1,66 @@ +import type { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { SeriesSelector } from './components/SeriesSelector.tsx'; +import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx'; +import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx'; +import { LabelsFilter } from './components/LabelsFilter.tsx'; + +export type ImpactMetricsControlsProps = { + selectedSeries: string; + onSeriesChange: (series: string) => void; + selectedRange: TimeRange; + onRangeChange: (range: TimeRange) => void; + beginAtZero: boolean; + onBeginAtZeroChange: (beginAtZero: boolean) => void; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; + selectedLabels: Record; + onLabelsChange: (labels: Record) => void; + availableLabels?: ImpactMetricsLabels; +}; + +export const ImpactMetricsControls: FC = ( + props, +) => ( + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + maxWidth: 400, + })} + > + + Select a custom metric to see its value over time. This can help you + understand the impact of your feature rollout on key outcomes, such + as system performance, usage patterns or error rates. + + + + + + + + + {props.availableLabels && ( + + )} + +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx new file mode 100644 index 0000000000..7fb884f4ba --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; +import { FormControlLabel, Checkbox } from '@mui/material'; + +export type BeginAtZeroToggleProps = { + value: boolean; + onChange: (beginAtZero: boolean) => void; +}; + +export const BeginAtZeroToggle: FC = ({ + value, + onChange, +}) => ( + onChange(e.target.checked)} + /> + } + label='Begin at zero' + /> +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx new file mode 100644 index 0000000000..b65d09f22a --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx @@ -0,0 +1,86 @@ +import type { FC } from 'react'; +import { Box, Autocomplete, TextField, Typography, Chip } from '@mui/material'; +import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; + +export type LabelsFilterProps = { + selectedLabels: Record; + onChange: (labels: Record) => void; + availableLabels: ImpactMetricsLabels; +}; + +export const LabelsFilter: FC = ({ + selectedLabels, + onChange, + availableLabels, +}) => { + const handleLabelChange = (labelKey: string, values: string[]) => { + const newLabels = { ...selectedLabels }; + if (values.length === 0) { + delete newLabels[labelKey]; + } else { + newLabels[labelKey] = values; + } + onChange(newLabels); + }; + + const clearAllLabels = () => { + onChange({}); + }; + + if (!availableLabels || Object.keys(availableLabels).length === 0) { + return null; + } + + return ( + + + Filter by labels + {Object.keys(selectedLabels).length > 0 && ( + + )} + + + {Object.entries(availableLabels).map(([labelKey, values]) => ( + + handleLabelChange(labelKey, newValues) + } + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...chipProps } = getTagProps({ + index, + }); + return ( + + ); + }) + } + renderInput={(params) => ( + + )} + sx={{ minWidth: 300 }} + /> + ))} + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx new file mode 100644 index 0000000000..86f204f3d9 --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx @@ -0,0 +1,26 @@ +import type { FC } from 'react'; +import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; + +export type TimeRange = 'hour' | 'day' | 'week' | 'month'; + +export type RangeSelectorProps = { + value: TimeRange; + onChange: (range: TimeRange) => void; +}; + +export const RangeSelector: FC = ({ value, onChange }) => ( + + Time + + +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx new file mode 100644 index 0000000000..cb7bebed9f --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx @@ -0,0 +1,55 @@ +import type { FC } from 'react'; +import { Autocomplete, TextField, Typography, Box } from '@mui/material'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; + +type SeriesOption = ImpactMetricsSeries & { name: string }; + +export type SeriesSelectorProps = { + value: string; + onChange: (series: string) => void; + options: SeriesOption[]; + loading?: boolean; +}; + +export const SeriesSelector: FC = ({ + value, + onChange, + options, + loading = false, +}) => ( + option.name} + value={options.find((option) => option.name === value) || null} + onChange={(_, newValue) => onChange(newValue?.name || '')} + disabled={loading} + renderOption={(props, option, { inputValue }) => ( + + + + + {option.name} + + + + + {option.help} + + + + + )} + renderInput={(params) => ( + + )} + noOptionsText='No metrics available' + sx={{ minWidth: 300 }} + /> +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx b/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx new file mode 100644 index 0000000000..902048694d --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; +import { styled } from '@mui/material'; +import { ImpactMetrics } from './ImpactMetrics.tsx'; + +const StyledWrapper = styled('div')(({ theme }) => ({ + paddingTop: theme.spacing(2), +})); + +const StyledContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(4), + paddingBottom: theme.spacing(4), +})); + +export const ImpactMetricsPage: FC = () => ( + + + + + +); diff --git a/frontend/src/component/insights/impact-metrics/hooks/useChartData.ts b/frontend/src/component/impact-metrics/hooks/useChartData.ts similarity index 100% rename from frontend/src/component/insights/impact-metrics/hooks/useChartData.ts rename to frontend/src/component/impact-metrics/hooks/useChartData.ts diff --git a/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts b/frontend/src/component/impact-metrics/hooks/useSeriesColor.ts similarity index 100% rename from frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts rename to frontend/src/component/impact-metrics/hooks/useSeriesColor.ts diff --git a/frontend/src/component/impact-metrics/hooks/useUrlState.ts b/frontend/src/component/impact-metrics/hooks/useUrlState.ts new file mode 100644 index 0000000000..275c9458f2 --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useUrlState.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; +import type { ChartConfig, ImpactMetricsState } from '../types.ts'; + +const encodeState = ( + state: ImpactMetricsState | null | undefined, +): string | undefined => + state && state.charts.length > 0 ? btoa(JSON.stringify(state)) : undefined; + +const decodeState = ( + value: string | (string | null)[] | null | undefined, +): ImpactMetricsState | null => { + if (typeof value !== 'string') return null; + try { + return JSON.parse(atob(value)); + } catch { + return null; + } +}; + +export const useUrlState = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [storedState, setStoredState] = + useLocalStorageState('impact-metrics-state', { + charts: [], + }); + + const urlState = decodeState(searchParams.get('data')); + const currentState = urlState || storedState; + + useEffect(() => { + if (urlState) { + setStoredState(urlState); + } else if (storedState.charts.length > 0) { + const encoded = encodeState(storedState); + if (encoded) { + setSearchParams( + (prev) => { + prev.set('data', encoded); + return prev; + }, + { replace: true }, + ); + } + } + }, [urlState, storedState.charts.length, setStoredState, setSearchParams]); + + const updateState = useCallback( + (newState: ImpactMetricsState) => { + setStoredState(newState); + setSearchParams( + (prev) => { + const encoded = encodeState(newState); + if (encoded) { + prev.set('data', encoded); + } else { + prev.delete('data'); + } + return prev; + }, + { replace: true }, + ); + }, + [setStoredState, setSearchParams], + ); + + const addChart = useCallback( + (config: Omit) => { + const newChart: ChartConfig = { + ...config, + id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + }; + + updateState({ + charts: [...currentState.charts, newChart], + }); + }, + [currentState.charts, updateState], + ); + + const updateChart = useCallback( + (id: string, updates: Partial) => { + updateState({ + charts: currentState.charts.map((chart) => + chart.id === id ? { ...chart, ...updates } : chart, + ), + }); + }, + [currentState.charts, updateState], + ); + + const deleteChart = useCallback( + (id: string) => { + updateState({ + charts: currentState.charts.filter((chart) => chart.id !== id), + }); + }, + [currentState.charts, updateState], + ); + + return { + charts: currentState.charts, + addChart, + updateChart, + deleteChart, + }; +}; diff --git a/frontend/src/component/impact-metrics/types.ts b/frontend/src/component/impact-metrics/types.ts new file mode 100644 index 0000000000..c319a3c463 --- /dev/null +++ b/frontend/src/component/impact-metrics/types.ts @@ -0,0 +1,12 @@ +export type ChartConfig = { + id: string; + selectedSeries: string; + selectedRange: 'hour' | 'day' | 'week' | 'month'; + beginAtZero: boolean; + selectedLabels: Record; + title?: string; +}; + +export type ImpactMetricsState = { + charts: ChartConfig[]; +}; diff --git a/frontend/src/component/insights/impact-metrics/utils.ts b/frontend/src/component/impact-metrics/utils.ts similarity index 100% rename from frontend/src/component/insights/impact-metrics/utils.ts rename to frontend/src/component/impact-metrics/utils.ts diff --git a/frontend/src/component/insights/Insights.tsx b/frontend/src/component/insights/Insights.tsx index f5936916d9..76ab5b3e92 100644 --- a/frontend/src/component/insights/Insights.tsx +++ b/frontend/src/component/insights/Insights.tsx @@ -7,20 +7,16 @@ import { StyledContainer } from './InsightsCharts.styles.ts'; import { LifecycleInsights } from './sections/LifecycleInsights.tsx'; import { PerformanceInsights } from './sections/PerformanceInsights.tsx'; import { UserInsights } from './sections/UserInsights.tsx'; -import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx'; const StyledWrapper = styled('div')(({ theme }) => ({ paddingTop: theme.spacing(2), })); const NewInsights: FC = () => { - const impactMetricsEnabled = useUiFlag('impactMetrics'); - return ( - {impactMetricsEnabled ? : null} diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx deleted file mode 100644 index 816cb4521e..0000000000 --- a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import type { FC } from 'react'; -import { useMemo, useState } from 'react'; -import { Box, Typography, Alert } from '@mui/material'; -import { - LineChart, - NotEnoughData, -} from '../components/LineChart/LineChart.tsx'; -import { InsightsSection } from '../sections/InsightsSection.tsx'; -import { - StyledChartContainer, - StyledWidget, - StyledWidgetStats, -} from 'component/insights/InsightsCharts.styles'; -import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; -import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; -import { usePlaceholderData } from '../hooks/usePlaceholderData.js'; -import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; -import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; -import { fromUnixTime } from 'date-fns'; -import { useChartData } from './hooks/useChartData.ts'; - -export const ImpactMetrics: FC = () => { - const [selectedSeries, setSelectedSeries] = useState(''); - const [selectedRange, setSelectedRange] = useState< - 'hour' | 'day' | 'week' | 'month' - >('day'); - const [beginAtZero, setBeginAtZero] = useState(false); - const [selectedLabels, setSelectedLabels] = useState< - Record - >({}); - - const handleSeriesChange = (series: string) => { - setSelectedSeries(series); - setSelectedLabels({}); // labels are series-specific - }; - - const { - metadata, - loading: metadataLoading, - error: metadataError, - } = useImpactMetricsMetadata(); - const { - data: { start, end, series: timeSeriesData, labels: availableLabels }, - loading: dataLoading, - error: dataError, - } = useImpactMetricsData( - selectedSeries - ? { - series: selectedSeries, - range: selectedRange, - labels: - Object.keys(selectedLabels).length > 0 - ? selectedLabels - : undefined, - } - : undefined, - ); - - const placeholderData = usePlaceholderData({ - fill: true, - type: 'constant', - }); - - const metricSeries = useMemo(() => { - if (!metadata?.series) { - return []; - } - return Object.entries(metadata.series).map(([name, rest]) => ({ - name, - ...rest, - })); - }, [metadata]); - - const data = useChartData(timeSeriesData); - - const hasError = metadataError || dataError; - const isLoading = metadataLoading || dataLoading; - const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; - const notEnoughData = useMemo( - () => - !isLoading && - (!timeSeriesData || - timeSeriesData.length === 0 || - !data.datasets.some((d) => d.data.length > 1)), - [data, isLoading, timeSeriesData], - ); - - const minTime = start - ? fromUnixTime(Number.parseInt(start, 10)) - : undefined; - const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; - - const placeholder = selectedSeries ? ( - - ) : ( - - ); - const cover = notEnoughData ? placeholder : isLoading; - - return ( - - - - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - width: '100%', - })} - > - - - {!selectedSeries && !isLoading ? ( - - Select a metric series to view the chart - - ) : null} - - - - - {hasError ? ( - - Failed to load impact metrics. Please check if - Prometheus is configured and the feature flag is - enabled. - - ) : null} - - typeof value === 'number' - ? formatLargeNumbers( - value, - ) - : (value as number), - }, - }, - }, - plugins: { - legend: { - display: - timeSeriesData && - timeSeriesData.length > 1, - position: 'bottom' as const, - labels: { - usePointStyle: true, - boxWidth: 8, - padding: 12, - }, - }, - }, - animations: { - x: { duration: 0 }, - y: { duration: 0 }, - }, - } - } - cover={cover} - /> - - - - ); -}; diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx deleted file mode 100644 index e658970d7c..0000000000 --- a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import type { FC } from 'react'; -import { - FormControl, - InputLabel, - Select, - MenuItem, - FormControlLabel, - Checkbox, - Box, - Autocomplete, - TextField, - Typography, - Chip, -} from '@mui/material'; -import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; -import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; -import { Highlighter } from 'component/common/Highlighter/Highlighter'; - -export interface ImpactMetricsControlsProps { - selectedSeries: string; - onSeriesChange: (series: string) => void; - selectedRange: 'hour' | 'day' | 'week' | 'month'; - onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void; - beginAtZero: boolean; - onBeginAtZeroChange: (beginAtZero: boolean) => void; - metricSeries: (ImpactMetricsSeries & { name: string })[]; - loading?: boolean; - selectedLabels: Record; - onLabelsChange: (labels: Record) => void; - availableLabels?: ImpactMetricsLabels; -} - -export const ImpactMetricsControls: FC = ({ - selectedSeries, - onSeriesChange, - selectedRange, - onRangeChange, - beginAtZero, - onBeginAtZeroChange, - metricSeries, - loading = false, - selectedLabels, - onLabelsChange, - availableLabels, -}) => { - const handleLabelChange = (labelKey: string, values: string[]) => { - const newLabels = { ...selectedLabels }; - if (values.length === 0) { - delete newLabels[labelKey]; - } else { - newLabels[labelKey] = values; - } - onLabelsChange(newLabels); - }; - - const clearAllLabels = () => { - onLabelsChange({}); - }; - - return ( - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(3), - maxWidth: 400, - })} - > - - Select a custom metric to see its value over time. This can help - you understand the impact of your feature rollout on key - outcomes, such as system performance, usage patterns or error - rates. - - - option.name} - value={ - metricSeries.find( - (option) => option.name === selectedSeries, - ) || null - } - onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} - disabled={loading} - renderOption={(props, option, { inputValue }) => ( - - - - - {option.name} - - - - - {option.help} - - - - - )} - renderInput={(params) => ( - - )} - noOptionsText='No metrics available' - sx={{ minWidth: 300 }} - /> - - - Time - - - - onBeginAtZeroChange(e.target.checked)} - /> - } - label='Begin at zero' - /> - {availableLabels && Object.keys(availableLabels).length > 0 ? ( - - - - Filter by labels - - {Object.keys(selectedLabels).length > 0 && ( - - )} - - - {Object.entries(availableLabels).map( - ([labelKey, values]) => ( - - handleLabelChange(labelKey, newValues) - } - renderTags={(value, getTagProps) => - value.map((option, index) => { - const { key, ...chipProps } = - getTagProps({ index }); - return ( - - ); - }) - } - renderInput={(params) => ( - - )} - sx={{ minWidth: 300 }} - /> - ), - )} - - ) : null} - - ); -}; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx index bec601a2af..447d542d22 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx @@ -15,6 +15,7 @@ import GroupsIcon from '@mui/icons-material/GroupsOutlined'; import RoleIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; import SettingsIcon from '@mui/icons-material/Settings'; import InsightsIcon from '@mui/icons-material/Insights'; +import ImpactMetricsIcon from '@mui/icons-material/TrendingUpOutlined'; import ApiAccessIcon from '@mui/icons-material/KeyOutlined'; import SingleSignOnIcon from '@mui/icons-material/AssignmentOutlined'; import NetworkIcon from '@mui/icons-material/HubOutlined'; @@ -44,6 +45,7 @@ const icons: Record< > = { '/search': FlagOutlinedIcon, '/insights': InsightsIcon, + '/impact-metrics': ImpactMetricsIcon, '/applications': ApplicationsIcon, '/context': ContextFieldsIcon, '/feature-toggle-type': FlagTypesIcon, diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx index bd77f94970..fc73828574 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx @@ -12,6 +12,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useNewAdminMenu } from 'hooks/useNewAdminMenu'; import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx'; import { ConfigurationAccordion } from './ConfigurationAccordion.tsx'; +import { useRoutes } from './useRoutes.ts'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; export const OtherLinksList = () => { const { uiConfig } = useUiConfig(); @@ -38,6 +40,7 @@ export const PrimaryNavigationList: FC<{ onClick: (activeItem: string) => void; activeItem?: string; }> = ({ mode, setMode, onClick, activeItem }) => { + const { routes } = useRoutes(); const PrimaryListItem = ({ href, text, @@ -53,6 +56,7 @@ export const PrimaryNavigationList: FC<{ ); const { isOss } = useUiConfig(); + const impactMetricsEnabled = useUiFlag('impactMetrics'); return ( @@ -63,6 +67,9 @@ export const PrimaryNavigationList: FC<{ {!isOss() ? ( ) : null} + {!isOss() && impactMetricsEnabled ? ( + + ) : null}