mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-27 13:49:10 +02:00
Merge branch 'main' into feat/impact-metrics-grid
This commit is contained in:
commit
400d3ce2a2
258
.github/workflows/ai-flag-cleanup-pr.yml
vendored
Normal file
258
.github/workflows/ai-flag-cleanup-pr.yml
vendored
Normal file
@ -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<<EOF" >> $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"
|
24
.github/workflows/ai-flag-cleanup.yml
vendored
24
.github/workflows/ai-flag-cleanup.yml
vendored
@ -1,22 +1,24 @@
|
|||||||
name: AI flag cleanup
|
name: AI flag cleanup
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [labeled]
|
types: [labeled]
|
||||||
|
workflow_dispatch:
|
||||||
permissions:
|
inputs:
|
||||||
pull-requests: write
|
issue-number:
|
||||||
contents: write
|
description: 'Flag completed issue number'
|
||||||
issues: read
|
required: true
|
||||||
|
type: number
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
flag-cleanup:
|
flag-cleanup:
|
||||||
uses: mirrajabi/aider-github-workflows/.github/workflows/aider-issue-to-pr.yml@v1.0.0
|
if: |
|
||||||
if: github.event.label.name == 'unleash-flag-completed'
|
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:
|
with:
|
||||||
issue-number: ${{ github.event.issue.number }}
|
issue-number: ${{ github.event.issue.number || inputs.issue-number }}
|
||||||
base-branch: ${{ github.event.repository.default_branch }}
|
|
||||||
chat-timeout: 10
|
|
||||||
api_key_env_name: GEMINI_API_KEY
|
|
||||||
model: gemini
|
model: gemini
|
||||||
|
api_key_env_name: GEMINI_API_KEY
|
||||||
secrets:
|
secrets:
|
||||||
api_key_env_value: ${{ secrets.GEMINI_API_KEY }}
|
api_key_env_value: ${{ secrets.GEMINI_API_KEY }}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
This section has been moved to a separate repository: https://github.com/Unleash/unleash-examples
|
|
@ -5,6 +5,7 @@ import { formatDateYMDHMS } from 'utils/formatDate';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { EventSchema } from 'openapi';
|
import type { EventSchema } from 'openapi';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
interface IEventCardProps {
|
interface IEventCardProps {
|
||||||
entry: EventSchema;
|
entry: EventSchema;
|
||||||
@ -72,6 +73,7 @@ export const StyledCodeSection = styled('div')(({ theme }) => ({
|
|||||||
|
|
||||||
const EventCard = ({ entry }: IEventCardProps) => {
|
const EventCard = ({ entry }: IEventCardProps) => {
|
||||||
const { locationSettings } = useLocationSettings();
|
const { locationSettings } = useLocationSettings();
|
||||||
|
const eventGroupingEnabled = useUiFlag('eventGrouping');
|
||||||
|
|
||||||
const createdAtFormatted = formatDateYMDHMS(
|
const createdAtFormatted = formatDateYMDHMS(
|
||||||
entry.createdAt,
|
entry.createdAt,
|
||||||
@ -138,6 +140,26 @@ const EventCard = ({ entry }: IEventCardProps) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={
|
||||||
|
eventGroupingEnabled &&
|
||||||
|
Boolean(entry.data?.changeRequestId)
|
||||||
|
}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<StyledDefinitionTerm>
|
||||||
|
Change request id:
|
||||||
|
</StyledDefinitionTerm>
|
||||||
|
<dd>
|
||||||
|
<Link
|
||||||
|
to={`/projects/${entry.project}/change-requests/${entry.data?.changeRequestId}`}
|
||||||
|
>
|
||||||
|
{String(entry.data?.changeRequestId)}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(entry.data || entry.preData)}
|
condition={Boolean(entry.data || entry.preData)}
|
||||||
|
@ -10,8 +10,9 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Alert,
|
Alert,
|
||||||
|
styled,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
|
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
NotEnoughData,
|
NotEnoughData,
|
||||||
@ -25,6 +26,31 @@ import { useChartData } from './hooks/useChartData.ts';
|
|||||||
import type { ChartConfig } from './types.ts';
|
import type { ChartConfig } from './types.ts';
|
||||||
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
|
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 {
|
export interface ChartConfigModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -182,15 +208,7 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* Configuration Panel */}
|
<StyledConfigPanel>
|
||||||
<Box
|
|
||||||
sx={(theme) => ({
|
|
||||||
flex: { xs: 'none', lg: '0 0 400px' },
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: theme.spacing(3),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<TextField
|
<TextField
|
||||||
label='Chart Title (optional)'
|
label='Chart Title (optional)'
|
||||||
value={title}
|
value={title}
|
||||||
@ -213,18 +231,10 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
|
|||||||
onLabelsChange={setSelectedLabels}
|
onLabelsChange={setSelectedLabels}
|
||||||
availableLabels={currentAvailableLabels}
|
availableLabels={currentAvailableLabels}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</StyledConfigPanel>
|
||||||
|
|
||||||
{/* Preview Panel */}
|
{/* Preview Panel */}
|
||||||
<Box
|
<StyledPreviewPanel>
|
||||||
sx={(theme) => ({
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
minHeight: { xs: '300px', lg: '400px' },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Typography variant='h6' color='text.secondary'>
|
<Typography variant='h6' color='text.secondary'>
|
||||||
Preview
|
Preview
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -316,7 +326,7 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
|
|||||||
cover={cover}
|
cover={cover}
|
||||||
/>
|
/>
|
||||||
</StyledChartContainer>
|
</StyledChartContainer>
|
||||||
</Box>
|
</StyledPreviewPanel>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
@ -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<string, string[]>;
|
||||||
|
onLabelsChange: (labels: Record<string, string[]>) => void;
|
||||||
|
availableLabels?: ImpactMetricsLabels;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
|
||||||
|
props,
|
||||||
|
) => (
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: theme.spacing(3),
|
||||||
|
maxWidth: 400,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
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.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<SeriesSelector
|
||||||
|
value={props.selectedSeries}
|
||||||
|
onChange={props.onSeriesChange}
|
||||||
|
options={props.metricSeries}
|
||||||
|
loading={props.loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RangeSelector
|
||||||
|
value={props.selectedRange}
|
||||||
|
onChange={props.onRangeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BeginAtZeroToggle
|
||||||
|
value={props.beginAtZero}
|
||||||
|
onChange={props.onBeginAtZeroChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{props.availableLabels && (
|
||||||
|
<LabelsFilter
|
||||||
|
selectedLabels={props.selectedLabels}
|
||||||
|
onChange={props.onLabelsChange}
|
||||||
|
availableLabels={props.availableLabels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
@ -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<BeginAtZeroToggleProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label='Begin at zero'
|
||||||
|
/>
|
||||||
|
);
|
@ -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<string, string[]>;
|
||||||
|
onChange: (labels: Record<string, string[]>) => void;
|
||||||
|
availableLabels: ImpactMetricsLabels;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LabelsFilter: FC<LabelsFilterProps> = ({
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant='subtitle2'>Filter by labels</Typography>
|
||||||
|
{Object.keys(selectedLabels).length > 0 && (
|
||||||
|
<Chip
|
||||||
|
label='Clear all'
|
||||||
|
size='small'
|
||||||
|
variant='outlined'
|
||||||
|
onClick={clearAllLabels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{Object.entries(availableLabels).map(([labelKey, values]) => (
|
||||||
|
<Autocomplete
|
||||||
|
key={labelKey}
|
||||||
|
multiple
|
||||||
|
options={values}
|
||||||
|
value={selectedLabels[labelKey] || []}
|
||||||
|
onChange={(_, newValues) =>
|
||||||
|
handleLabelChange(labelKey, newValues)
|
||||||
|
}
|
||||||
|
renderTags={(value, getTagProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const { key, ...chipProps } = getTagProps({
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
{...chipProps}
|
||||||
|
key={key}
|
||||||
|
label={option}
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={labelKey}
|
||||||
|
placeholder='Select values...'
|
||||||
|
variant='outlined'
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={{ minWidth: 300 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
@ -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<RangeSelectorProps> = ({ value, onChange }) => (
|
||||||
|
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel id='range-select-label'>Time</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId='range-select-label'
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value as TimeRange)}
|
||||||
|
label='Time Range'
|
||||||
|
>
|
||||||
|
<MenuItem value='hour'>Last hour</MenuItem>
|
||||||
|
<MenuItem value='day'>Last 24 hours</MenuItem>
|
||||||
|
<MenuItem value='week'>Last 7 days</MenuItem>
|
||||||
|
<MenuItem value='month'>Last 30 days</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
@ -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<SeriesSelectorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
loading = false,
|
||||||
|
}) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
value={options.find((option) => option.name === value) || null}
|
||||||
|
onChange={(_, newValue) => onChange(newValue?.name || '')}
|
||||||
|
disabled={loading}
|
||||||
|
renderOption={(props, option, { inputValue }) => (
|
||||||
|
<Box component='li' {...props}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant='body2'>
|
||||||
|
<Highlighter search={inputValue}>
|
||||||
|
{option.name}
|
||||||
|
</Highlighter>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='caption' color='text.secondary'>
|
||||||
|
<Highlighter search={inputValue}>
|
||||||
|
{option.help}
|
||||||
|
</Highlighter>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label='Data series'
|
||||||
|
placeholder='Search for a metric…'
|
||||||
|
variant='outlined'
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
noOptionsText='No metrics available'
|
||||||
|
sx={{ minWidth: 300 }}
|
||||||
|
/>
|
||||||
|
);
|
@ -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<string>('');
|
|
||||||
const [selectedRange, setSelectedRange] = useState<
|
|
||||||
'hour' | 'day' | 'week' | 'month'
|
|
||||||
>('day');
|
|
||||||
const [beginAtZero, setBeginAtZero] = useState(false);
|
|
||||||
const [selectedLabels, setSelectedLabels] = useState<
|
|
||||||
Record<string, string[]>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
|
|
||||||
) : (
|
|
||||||
<NotEnoughData
|
|
||||||
title='Select a metric series to view the chart.'
|
|
||||||
description=''
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const cover = notEnoughData ? placeholder : isLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InsightsSection title='Impact metrics'>
|
|
||||||
<StyledWidget>
|
|
||||||
<StyledWidgetStats>
|
|
||||||
<Box
|
|
||||||
sx={(theme) => ({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
width: '100%',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ImpactMetricsControls
|
|
||||||
selectedSeries={selectedSeries}
|
|
||||||
onSeriesChange={handleSeriesChange}
|
|
||||||
selectedRange={selectedRange}
|
|
||||||
onRangeChange={setSelectedRange}
|
|
||||||
beginAtZero={beginAtZero}
|
|
||||||
onBeginAtZeroChange={setBeginAtZero}
|
|
||||||
metricSeries={metricSeries}
|
|
||||||
loading={metadataLoading}
|
|
||||||
selectedLabels={selectedLabels}
|
|
||||||
onLabelsChange={setSelectedLabels}
|
|
||||||
availableLabels={availableLabels}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!selectedSeries && !isLoading ? (
|
|
||||||
<Typography variant='body2' color='text.secondary'>
|
|
||||||
Select a metric series to view the chart
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</StyledWidgetStats>
|
|
||||||
|
|
||||||
<StyledChartContainer>
|
|
||||||
{hasError ? (
|
|
||||||
<Alert severity='error'>
|
|
||||||
Failed to load impact metrics. Please check if
|
|
||||||
Prometheus is configured and the feature flag is
|
|
||||||
enabled.
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
<LineChart
|
|
||||||
data={
|
|
||||||
notEnoughData || isLoading ? placeholderData : data
|
|
||||||
}
|
|
||||||
overrideOptions={
|
|
||||||
shouldShowPlaceholder
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'time',
|
|
||||||
min: minTime?.getTime(),
|
|
||||||
max: maxTime?.getTime(),
|
|
||||||
time: {
|
|
||||||
unit: getTimeUnit(
|
|
||||||
selectedRange,
|
|
||||||
),
|
|
||||||
displayFormats: {
|
|
||||||
[getTimeUnit(
|
|
||||||
selectedRange,
|
|
||||||
)]:
|
|
||||||
getDisplayFormat(
|
|
||||||
selectedRange,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
tooltipFormat: 'PPpp',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
beginAtZero,
|
|
||||||
title: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
precision: 0,
|
|
||||||
callback: (
|
|
||||||
value: unknown,
|
|
||||||
): string | number =>
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</StyledChartContainer>
|
|
||||||
</StyledWidget>
|
|
||||||
</InsightsSection>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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<string, string[]>;
|
|
||||||
onLabelsChange: (labels: Record<string, string[]>) => void;
|
|
||||||
availableLabels?: ImpactMetricsLabels;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
|
|
||||||
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 (
|
|
||||||
<Box
|
|
||||||
sx={(theme) => ({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: theme.spacing(3),
|
|
||||||
maxWidth: 400,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Typography variant='body2' color='text.secondary'>
|
|
||||||
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.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Autocomplete
|
|
||||||
options={metricSeries}
|
|
||||||
getOptionLabel={(option) => option.name}
|
|
||||||
value={
|
|
||||||
metricSeries.find(
|
|
||||||
(option) => option.name === selectedSeries,
|
|
||||||
) || null
|
|
||||||
}
|
|
||||||
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
|
|
||||||
disabled={loading}
|
|
||||||
renderOption={(props, option, { inputValue }) => (
|
|
||||||
<Box component='li' {...props}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Typography variant='body2'>
|
|
||||||
<Highlighter search={inputValue}>
|
|
||||||
{option.name}
|
|
||||||
</Highlighter>
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant='caption'
|
|
||||||
color='text.secondary'
|
|
||||||
>
|
|
||||||
<Highlighter search={inputValue}>
|
|
||||||
{option.help}
|
|
||||||
</Highlighter>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label='Data series'
|
|
||||||
placeholder='Search for a metric…'
|
|
||||||
variant='outlined'
|
|
||||||
size='small'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
noOptionsText='No metrics available'
|
|
||||||
sx={{ minWidth: 300 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
|
|
||||||
<InputLabel id='range-select-label'>Time</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId='range-select-label'
|
|
||||||
value={selectedRange}
|
|
||||||
onChange={(e) =>
|
|
||||||
onRangeChange(
|
|
||||||
e.target.value as 'hour' | 'day' | 'week' | 'month',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label='Time Range'
|
|
||||||
>
|
|
||||||
<MenuItem value='hour'>Last hour</MenuItem>
|
|
||||||
<MenuItem value='day'>Last 24 hours</MenuItem>
|
|
||||||
<MenuItem value='week'>Last 7 days</MenuItem>
|
|
||||||
<MenuItem value='month'>Last 30 days</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={beginAtZero}
|
|
||||||
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label='Begin at zero'
|
|
||||||
/>
|
|
||||||
{availableLabels && Object.keys(availableLabels).length > 0 ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant='subtitle2'>
|
|
||||||
Filter by labels
|
|
||||||
</Typography>
|
|
||||||
{Object.keys(selectedLabels).length > 0 && (
|
|
||||||
<Chip
|
|
||||||
label='Clear all'
|
|
||||||
size='small'
|
|
||||||
variant='outlined'
|
|
||||||
onClick={clearAllLabels}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{Object.entries(availableLabels).map(
|
|
||||||
([labelKey, values]) => (
|
|
||||||
<Autocomplete
|
|
||||||
key={labelKey}
|
|
||||||
multiple
|
|
||||||
options={values}
|
|
||||||
value={selectedLabels[labelKey] || []}
|
|
||||||
onChange={(_, newValues) =>
|
|
||||||
handleLabelChange(labelKey, newValues)
|
|
||||||
}
|
|
||||||
renderTags={(value, getTagProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const { key, ...chipProps } =
|
|
||||||
getTagProps({ index });
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
{...chipProps}
|
|
||||||
key={key}
|
|
||||||
label={option}
|
|
||||||
size='small'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={labelKey}
|
|
||||||
placeholder='Select values...'
|
|
||||||
variant='outlined'
|
|
||||||
size='small'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
sx={{ minWidth: 300 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,85 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTheme } from '@mui/material';
|
|
||||||
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
|
|
||||||
import { useSeriesColor } from './useSeriesColor.ts';
|
|
||||||
import { getSeriesLabel } from '../utils.ts';
|
|
||||||
|
|
||||||
export const useChartData = (
|
|
||||||
timeSeriesData: ImpactMetricsSeries[] | undefined,
|
|
||||||
) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const getSeriesColor = useSeriesColor();
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!timeSeriesData || timeSeriesData.length === 0) {
|
|
||||||
return {
|
|
||||||
labels: [],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: [],
|
|
||||||
borderColor: theme.palette.primary.main,
|
|
||||||
backgroundColor: theme.palette.primary.light,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeSeriesData.length === 1) {
|
|
||||||
const series = timeSeriesData[0];
|
|
||||||
const timestamps = series.data.map(
|
|
||||||
([epochTimestamp]) => new Date(epochTimestamp * 1000),
|
|
||||||
);
|
|
||||||
const values = series.data.map(([, value]) => value);
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: timestamps,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: values,
|
|
||||||
borderColor: theme.palette.primary.main,
|
|
||||||
backgroundColor: theme.palette.primary.light,
|
|
||||||
label: getSeriesLabel(series.metric),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const allTimestamps = new Set<number>();
|
|
||||||
timeSeriesData.forEach((series) => {
|
|
||||||
series.data.forEach(([timestamp]) => {
|
|
||||||
allTimestamps.add(timestamp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const sortedTimestamps = Array.from(allTimestamps).sort(
|
|
||||||
(a, b) => a - b,
|
|
||||||
);
|
|
||||||
const labels = sortedTimestamps.map(
|
|
||||||
(timestamp) => new Date(timestamp * 1000),
|
|
||||||
);
|
|
||||||
|
|
||||||
const datasets = timeSeriesData.map((series) => {
|
|
||||||
const seriesLabel = getSeriesLabel(series.metric);
|
|
||||||
const color = getSeriesColor(seriesLabel);
|
|
||||||
|
|
||||||
const dataMap = new Map(series.data);
|
|
||||||
|
|
||||||
const data = sortedTimestamps.map(
|
|
||||||
(timestamp) => dataMap.get(timestamp) ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: seriesLabel,
|
|
||||||
data,
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: color,
|
|
||||||
fill: false,
|
|
||||||
spanGaps: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels,
|
|
||||||
datasets,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [timeSeriesData, theme, getSeriesColor]);
|
|
||||||
};
|
|
@ -1,17 +0,0 @@
|
|||||||
import { useTheme } from '@mui/material';
|
|
||||||
|
|
||||||
export const useSeriesColor = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const colors = theme.palette.charts.series;
|
|
||||||
|
|
||||||
return (seriesLabel: string): string => {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < seriesLabel.length; i++) {
|
|
||||||
const char = seriesLabel.charCodeAt(i);
|
|
||||||
hash = (hash << 5) - hash + char;
|
|
||||||
hash = hash & hash; // Convert to 32-bit integer
|
|
||||||
}
|
|
||||||
const index = Math.abs(hash) % colors.length;
|
|
||||||
return colors[index];
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,60 +0,0 @@
|
|||||||
export const getTimeUnit = (selectedRange: string) => {
|
|
||||||
switch (selectedRange) {
|
|
||||||
case 'hour':
|
|
||||||
return 'minute';
|
|
||||||
case 'day':
|
|
||||||
return 'hour';
|
|
||||||
case 'week':
|
|
||||||
return 'day';
|
|
||||||
case 'month':
|
|
||||||
return 'day';
|
|
||||||
default:
|
|
||||||
return 'hour';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDisplayFormat = (selectedRange: string) => {
|
|
||||||
switch (selectedRange) {
|
|
||||||
case 'hour':
|
|
||||||
case 'day':
|
|
||||||
return 'HH:mm';
|
|
||||||
case 'week':
|
|
||||||
case 'month':
|
|
||||||
return 'MMM dd';
|
|
||||||
default:
|
|
||||||
return 'MMM dd HH:mm';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSeriesLabel = (metric: Record<string, string>): string => {
|
|
||||||
const { __name__, ...labels } = metric;
|
|
||||||
|
|
||||||
const labelParts = Object.entries(labels)
|
|
||||||
.filter(([key, value]) => key !== '__name__' && value)
|
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
if (!__name__ && !labelParts) {
|
|
||||||
return 'Series';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!__name__) {
|
|
||||||
return labelParts;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!labelParts) {
|
|
||||||
return __name__;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${__name__} (${labelParts})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatLargeNumbers = (value: number): string => {
|
|
||||||
if (value >= 1000000) {
|
|
||||||
return `${(value / 1000000).toFixed(0)}M`;
|
|
||||||
}
|
|
||||||
if (value >= 1000) {
|
|
||||||
return `${(value / 1000).toFixed(0)}k`;
|
|
||||||
}
|
|
||||||
return value.toString();
|
|
||||||
};
|
|
@ -93,6 +93,7 @@ export type UiFlags = {
|
|||||||
impactMetrics?: boolean;
|
impactMetrics?: boolean;
|
||||||
crDiffView?: boolean;
|
crDiffView?: boolean;
|
||||||
changeRequestApproverEmails?: boolean;
|
changeRequestApproverEmails?: boolean;
|
||||||
|
eventGrouping?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -196,8 +196,14 @@ export default class ClientMetricsServiceV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async registerImpactMetrics(impactMetrics: Metric[]) {
|
async registerImpactMetrics(impactMetrics: Metric[]) {
|
||||||
const value = await impactMetricsSchema.validateAsync(impactMetrics);
|
try {
|
||||||
this.impactMetricsTranslator.translateMetrics(value);
|
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(
|
async registerClientMetrics(
|
||||||
|
@ -26,6 +26,20 @@ const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) =>
|
|||||||
})
|
})
|
||||||
.expect(status);
|
.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 () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('impact_metrics', getLogger);
|
db = await dbInit('impact_metrics', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
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([]);
|
await sendImpactMetrics([]);
|
||||||
// missing help
|
// missing help = no error but value ignored
|
||||||
await sendImpactMetrics(
|
await sendImpactMetrics(
|
||||||
[
|
[
|
||||||
// @ts-expect-error
|
// @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
|
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).toContain('# TYPE labeled_counter counter');
|
||||||
expect(metricsText).toMatch(/labeled_counter{foo="bar"} 15/);
|
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/);
|
||||||
|
});
|
||||||
|
@ -230,7 +230,7 @@ export default class ClientMetricsController extends Controller {
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
} else {
|
} else {
|
||||||
const { body, ip: clientIp } = req;
|
const { body, ip: clientIp } = req;
|
||||||
const { metrics, applications } = body;
|
const { metrics, applications, impactMetrics } = body;
|
||||||
try {
|
try {
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
for (const app of applications) {
|
for (const app of applications) {
|
||||||
@ -275,6 +275,17 @@ export default class ClientMetricsController extends Controller {
|
|||||||
);
|
);
|
||||||
this.config.eventBus.emit(CLIENT_METRICS, data);
|
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);
|
await Promise.all(promises);
|
||||||
|
|
||||||
res.status(202).end();
|
res.status(202).end();
|
||||||
|
@ -2,6 +2,7 @@ import type { FromSchema } from 'json-schema-to-ts';
|
|||||||
import { bulkRegistrationSchema } from './bulk-registration-schema.js';
|
import { bulkRegistrationSchema } from './bulk-registration-schema.js';
|
||||||
import { dateSchema } from './date-schema.js';
|
import { dateSchema } from './date-schema.js';
|
||||||
import { clientMetricsEnvSchema } from './client-metrics-env-schema.js';
|
import { clientMetricsEnvSchema } from './client-metrics-env-schema.js';
|
||||||
|
import { impactMetricsSchema } from './impact-metrics-schema.js';
|
||||||
|
|
||||||
export const bulkMetricsSchema = {
|
export const bulkMetricsSchema = {
|
||||||
$id: '#/components/schemas/bulkMetricsSchema',
|
$id: '#/components/schemas/bulkMetricsSchema',
|
||||||
@ -25,12 +26,21 @@ export const bulkMetricsSchema = {
|
|||||||
$ref: '#/components/schemas/clientMetricsEnvSchema',
|
$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: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
bulkRegistrationSchema,
|
bulkRegistrationSchema,
|
||||||
dateSchema,
|
dateSchema,
|
||||||
clientMetricsEnvSchema,
|
clientMetricsEnvSchema,
|
||||||
|
impactMetricsSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
60
src/lib/openapi/spec/impact-metrics-schema.ts
Normal file
60
src/lib/openapi/spec/impact-metrics-schema.ts
Normal file
@ -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<typeof impactMetricsSchema>;
|
@ -113,6 +113,7 @@ export * from './health-overview-schema.js';
|
|||||||
export * from './health-report-schema.js';
|
export * from './health-report-schema.js';
|
||||||
export * from './id-schema.js';
|
export * from './id-schema.js';
|
||||||
export * from './ids-schema.js';
|
export * from './ids-schema.js';
|
||||||
|
export * from './impact-metrics-schema.js';
|
||||||
export * from './import-toggles-schema.js';
|
export * from './import-toggles-schema.js';
|
||||||
export * from './import-toggles-validate-item-schema.js';
|
export * from './import-toggles-validate-item-schema.js';
|
||||||
export * from './import-toggles-validate-schema.js';
|
export * from './import-toggles-validate-schema.js';
|
||||||
|
@ -62,7 +62,8 @@ export type IFlagKey =
|
|||||||
| 'createFlagDialogCache'
|
| 'createFlagDialogCache'
|
||||||
| 'improvedJsonDiff'
|
| 'improvedJsonDiff'
|
||||||
| 'crDiffView'
|
| 'crDiffView'
|
||||||
| 'changeRequestApproverEmails';
|
| 'changeRequestApproverEmails'
|
||||||
|
| 'eventGrouping';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -291,6 +292,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_IMPACT_METRICS,
|
process.env.UNLEASH_EXPERIMENTAL_IMPACT_METRICS,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
eventGrouping: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_EVENT_GROUPING,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -58,6 +58,7 @@ process.nextTick(async () => {
|
|||||||
improvedJsonDiff: true,
|
improvedJsonDiff: true,
|
||||||
impactMetrics: true,
|
impactMetrics: true,
|
||||||
crDiffView: true,
|
crDiffView: true,
|
||||||
|
eventGrouping: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
Loading…
Reference in New Issue
Block a user