mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-27 13:49:10 +02:00
chore: implement unknown flags UX feedback (#10519)
https://linear.app/unleash/issue/2-3809/implement-the-latest-feedback-from-ux Implements the latest feedback from UX regarding **unknown flags**. Bit unsure about our column headers. E.g. instead of "Last seen" and "Seen in Unleash" we could call them "Reported" and "Last event". <img width="1490" height="838" alt="image" src="https://github.com/user-attachments/assets/30ca2570-1395-429f-8d60-ccc6fe83ba92" />
This commit is contained in:
parent
d76b676fb8
commit
885d3e1817
@ -1,17 +1,17 @@
|
|||||||
import { Tooltip, Typography } from '@mui/material';
|
import { Tooltip, Typography } from '@mui/material';
|
||||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
import type { FC } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { formatDateYMD } from 'utils/formatDate';
|
import { formatDateYMD } from 'utils/formatDate';
|
||||||
import { TextCell } from '../TextCell/TextCell.tsx';
|
import { TextCell } from '../TextCell/TextCell.tsx';
|
||||||
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
||||||
import type { ColumnInstance } from 'react-table';
|
import type { ColumnInstance } from 'react-table';
|
||||||
|
|
||||||
interface ITimeAgoCellProps {
|
export interface ITimeAgoCellProps {
|
||||||
value?: string | number | Date | null;
|
value?: string | number | Date | null;
|
||||||
column?: ColumnInstance;
|
column?: ColumnInstance;
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
title?: (date: string) => string;
|
title?: (date?: string) => ReactNode;
|
||||||
dateFormat?: (value: string | number | Date, locale: string) => string;
|
dateFormat?: (value: string | number | Date, locale: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,18 +20,19 @@ export const TimeAgoCell: FC<ITimeAgoCellProps> = ({
|
|||||||
column,
|
column,
|
||||||
live = false,
|
live = false,
|
||||||
emptyText = 'Never',
|
emptyText = 'Never',
|
||||||
title = (date) => (column ? `${column.Header}: ${date}` : date),
|
title = (date) =>
|
||||||
|
date ? (column ? `${column.Header}: ${date}` : date) : '',
|
||||||
dateFormat = formatDateYMD,
|
dateFormat = formatDateYMD,
|
||||||
}) => {
|
}) => {
|
||||||
const { locationSettings } = useLocationSettings();
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
if (!value) return <TextCell>{emptyText}</TextCell>;
|
const tooltip = value
|
||||||
|
? title(dateFormat(value, locationSettings.locale))
|
||||||
const date = dateFormat(value, locationSettings.locale);
|
: title();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextCell>
|
<TextCell>
|
||||||
<Tooltip title={title(date)} arrow>
|
<Tooltip title={tooltip} arrow>
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
noWrap
|
||||||
sx={{
|
sx={{
|
||||||
@ -42,7 +43,11 @@ export const TimeAgoCell: FC<ITimeAgoCellProps> = ({
|
|||||||
variant='body2'
|
variant='body2'
|
||||||
data-loading
|
data-loading
|
||||||
>
|
>
|
||||||
<TimeAgo date={value} refresh={live} />
|
{value ? (
|
||||||
|
<TimeAgo date={value} refresh={live} />
|
||||||
|
) : (
|
||||||
|
emptyText
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TextCell>
|
</TextCell>
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
import { Box, styled } from '@mui/material';
|
|
||||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import EventNoteIcon from '@mui/icons-material/EventNote';
|
|
||||||
import type { UnknownFlag } from './hooks/useUnknownFlags.js';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const StyledBox = styled(Box)(() => ({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface IUnknownFlagsActionsCellProps {
|
|
||||||
unknownFlag: UnknownFlag;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UnknownFlagsActionsCell = ({
|
|
||||||
unknownFlag,
|
|
||||||
}: IUnknownFlagsActionsCellProps) => (
|
|
||||||
<StyledBox>
|
|
||||||
<PermissionIconButton
|
|
||||||
component={Link}
|
|
||||||
data-loading
|
|
||||||
to={`/history?feature=${encodeURIComponent(`IS:${unknownFlag.name}`)}`}
|
|
||||||
permission={ADMIN}
|
|
||||||
tooltipProps={{
|
|
||||||
title: 'See events',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EventNoteIcon />
|
|
||||||
</PermissionIconButton>
|
|
||||||
</StyledBox>
|
|
||||||
);
|
|
@ -0,0 +1,37 @@
|
|||||||
|
import type { UnknownFlag } from './hooks/useUnknownFlags.js';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
TimeAgoCell,
|
||||||
|
type ITimeAgoCellProps,
|
||||||
|
} from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell.js';
|
||||||
|
import AccessContext from 'contexts/AccessContext.js';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
interface IUnknownFlagsSeenInUnleashCellProps extends ITimeAgoCellProps {
|
||||||
|
unknownFlag: UnknownFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnknownFlagsSeenInUnleashCell = ({
|
||||||
|
unknownFlag,
|
||||||
|
...props
|
||||||
|
}: IUnknownFlagsSeenInUnleashCellProps) => {
|
||||||
|
const { isAdmin } = useContext(AccessContext);
|
||||||
|
const value = unknownFlag.lastEventAt;
|
||||||
|
const title = value
|
||||||
|
? (date) => `Last event: ${date}`
|
||||||
|
: () => 'This flag has never existed in Unleash';
|
||||||
|
|
||||||
|
const TimeAgo = <TimeAgoCell value={value} title={title} {...props} />;
|
||||||
|
|
||||||
|
if (value && isAdmin) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/history?feature=${encodeURIComponent(`IS:${unknownFlag.name}`)}`}
|
||||||
|
>
|
||||||
|
{TimeAgo}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeAgo;
|
||||||
|
};
|
@ -16,7 +16,8 @@ import { formatDateYMDHMS } from 'utils/formatDate.js';
|
|||||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell.js';
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell.js';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag.js';
|
import { useUiFlag } from 'hooks/useUiFlag.js';
|
||||||
import NotFound from 'component/common/NotFound/NotFound.js';
|
import NotFound from 'component/common/NotFound/NotFound.js';
|
||||||
import { UnknownFlagsActionsCell } from './UnknownFlagsActionsCell.js';
|
import { UnknownFlagsSeenInUnleashCell } from './UnknownFlagsSeenInUnleashCell.js';
|
||||||
|
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon.js';
|
||||||
|
|
||||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
marginBottom: theme.spacing(3),
|
marginBottom: theme.spacing(3),
|
||||||
@ -25,13 +26,12 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
|
|||||||
const StyledAlertContent = styled('div')(({ theme }) => ({
|
const StyledAlertContent = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: theme.spacing(1),
|
gap: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledUl = styled('ul')(({ theme }) => ({
|
const StyledHeader = styled('div')({
|
||||||
paddingTop: theme.spacing(1),
|
display: 'flex',
|
||||||
paddingBottom: theme.spacing(1),
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
export const UnknownFlagsTable = () => {
|
export const UnknownFlagsTable = () => {
|
||||||
const { unknownFlags, loading } = useUnknownFlags();
|
const { unknownFlags, loading } = useUnknownFlags();
|
||||||
@ -46,13 +46,14 @@ export const UnknownFlagsTable = () => {
|
|||||||
{
|
{
|
||||||
Header: 'Flag name',
|
Header: 'Flag name',
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
minWidth: 200,
|
minWidth: 100,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Application',
|
Header: 'Application',
|
||||||
accessor: 'appName',
|
accessor: 'appName',
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
minWidth: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Environment',
|
Header: 'Environment',
|
||||||
@ -60,27 +61,47 @@ export const UnknownFlagsTable = () => {
|
|||||||
searchable: true,
|
searchable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Last seen',
|
Header: (
|
||||||
|
<StyledHeader>
|
||||||
|
Reported
|
||||||
|
<HelpIcon
|
||||||
|
tooltip={`Flags reported when your SDK evaluates them but they don't exist in Unleash`}
|
||||||
|
size='16px'
|
||||||
|
/>
|
||||||
|
</StyledHeader>
|
||||||
|
),
|
||||||
accessor: 'seenAt',
|
accessor: 'seenAt',
|
||||||
Cell: ({ value, column }) => (
|
Cell: ({ value }) => (
|
||||||
<TimeAgoCell
|
<TimeAgoCell
|
||||||
value={value}
|
value={value}
|
||||||
column={column}
|
title={(date) => `Reported: ${date}`}
|
||||||
dateFormat={formatDateYMDHMS}
|
dateFormat={formatDateYMDHMS}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
width: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Actions',
|
Header: (
|
||||||
id: 'Actions',
|
<StyledHeader>
|
||||||
align: 'center',
|
Last event
|
||||||
|
<HelpIcon
|
||||||
|
tooltip='Events are only logged for feature flags that have been set up in Unleash first'
|
||||||
|
size='16px'
|
||||||
|
/>
|
||||||
|
</StyledHeader>
|
||||||
|
),
|
||||||
|
accessor: 'lastEventAt',
|
||||||
Cell: ({
|
Cell: ({
|
||||||
row: { original: unknownFlag },
|
row: { original: unknownFlag },
|
||||||
}: { row: { original: UnknownFlag } }) => (
|
}: {
|
||||||
<UnknownFlagsActionsCell unknownFlag={unknownFlag} />
|
row: { original: UnknownFlag };
|
||||||
|
}) => (
|
||||||
|
<UnknownFlagsSeenInUnleashCell
|
||||||
|
unknownFlag={unknownFlag}
|
||||||
|
dateFormat={formatDateYMDHMS}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
width: 100,
|
width: 150,
|
||||||
disableSortBy: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@ -121,7 +142,7 @@ export const UnknownFlagsTable = () => {
|
|||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`Unknown flags (${rows.length})`}
|
title={`Unknown flag report (${rows.length})`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -154,36 +175,34 @@ export const UnknownFlagsTable = () => {
|
|||||||
<StyledAlert severity='info'>
|
<StyledAlert severity='info'>
|
||||||
<StyledAlertContent>
|
<StyledAlertContent>
|
||||||
<p>
|
<p>
|
||||||
<strong>Unknown flags</strong> are feature flags that
|
<b>
|
||||||
your SDKs tried to evaluate but which Unleash doesn't
|
Clean up unknown flags to keep your code and
|
||||||
recognize. Tracking them helps you catch typos, remove
|
configuration in sync
|
||||||
outdated flags, and keep your code and configuration in
|
</b>
|
||||||
sync. These can include:
|
<br />
|
||||||
</p>
|
Unknown flags are feature flags that your SDKs tried to
|
||||||
|
evaluate but which Unleash doesn't recognize.
|
||||||
<StyledUl>
|
|
||||||
<li>
|
|
||||||
<b>Missing flags</b>: typos or flags referenced in
|
|
||||||
code that don't exist in Unleash.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<b>Invalid flags</b>: flags with malformed or
|
|
||||||
unexpected names, unsupported by Unleash.
|
|
||||||
</li>
|
|
||||||
</StyledUl>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Each row in the table represents an{' '}
|
|
||||||
<strong>unknown flag report</strong>, which is a unique
|
|
||||||
combination of <em>flag name</em>, <em>application</em>,
|
|
||||||
and <em>environment</em>. The same flag name may appear
|
|
||||||
multiple times if it's been seen in different
|
|
||||||
applications or environments.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
We display up to 1,000 unknown flag reports from the
|
<b>Unknown flags can include:</b>
|
||||||
last 24 hours. Older reports are automatically pruned.
|
<ul>
|
||||||
|
<li>
|
||||||
|
Missing flags: typos or flags referenced in code
|
||||||
|
that don't exist in Unleash.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Invalid flags: flags with malformed or
|
||||||
|
unexpected names, unsupported by Unleash.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>Why do I see the same flag name multiple times?</b>
|
||||||
|
<br />
|
||||||
|
The same flag name will appear multiple times if it's
|
||||||
|
been seen in different applications or environments.
|
||||||
</p>
|
</p>
|
||||||
</StyledAlertContent>
|
</StyledAlertContent>
|
||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
|
@ -10,6 +10,7 @@ export type UnknownFlag = {
|
|||||||
appName: string;
|
appName: string;
|
||||||
seenAt: Date;
|
seenAt: Date;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
lastEventAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UnknownFlagsResponse = {
|
type UnknownFlagsResponse = {
|
||||||
|
@ -2,6 +2,7 @@ import type { Db } from '../../../db/db.js';
|
|||||||
import type { Logger, LogProvider } from '../../../logger.js';
|
import type { Logger, LogProvider } from '../../../logger.js';
|
||||||
|
|
||||||
const TABLE = 'unknown_flags';
|
const TABLE = 'unknown_flags';
|
||||||
|
const TABLE_EVENTS = 'events';
|
||||||
const MAX_INSERT_BATCH_SIZE = 100;
|
const MAX_INSERT_BATCH_SIZE = 100;
|
||||||
|
|
||||||
export type UnknownFlag = {
|
export type UnknownFlag = {
|
||||||
@ -9,6 +10,7 @@ export type UnknownFlag = {
|
|||||||
appName: string;
|
appName: string;
|
||||||
seenAt: Date;
|
seenAt: Date;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
lastEventAt?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryParams = {
|
export type QueryParams = {
|
||||||
@ -64,11 +66,17 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
|
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
|
||||||
let query = this.db(TABLE).select(
|
let query = this.db(`${TABLE} AS uf`).select(
|
||||||
'name',
|
'uf.name',
|
||||||
'app_name',
|
'uf.app_name',
|
||||||
'seen_at',
|
'uf.seen_at',
|
||||||
'environment',
|
'uf.environment',
|
||||||
|
this.db.raw(
|
||||||
|
`(SELECT MAX(e.created_at)
|
||||||
|
FROM ${TABLE_EVENTS} AS e
|
||||||
|
WHERE e.feature_name = uf.name)
|
||||||
|
AS last_event_at`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (orderBy) {
|
if (orderBy) {
|
||||||
@ -84,8 +92,9 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
|
|||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
appName: row.app_name,
|
appName: row.app_name,
|
||||||
seenAt: new Date(row.seen_at),
|
seenAt: row.seen_at,
|
||||||
environment: row.environment,
|
environment: row.environment,
|
||||||
|
lastEventAt: row.last_event_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +31,14 @@ export const unknownFlagSchema = {
|
|||||||
'The environment in which the unknown flag was reported.',
|
'The environment in which the unknown flag was reported.',
|
||||||
example: 'production',
|
example: 'production',
|
||||||
},
|
},
|
||||||
|
lastEventAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description:
|
||||||
|
'The date and time when the last event for the unknown flag name occurred, if any.',
|
||||||
|
example: '2023-10-01T12:00:00Z',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {},
|
components: {},
|
||||||
} as const;
|
} as const;
|
||||||
|
Loading…
Reference in New Issue
Block a user