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 { useLocationSettings } from 'hooks/useLocationSettings';
|
||||
import type { FC } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { formatDateYMD } from 'utils/formatDate';
|
||||
import { TextCell } from '../TextCell/TextCell.tsx';
|
||||
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
||||
import type { ColumnInstance } from 'react-table';
|
||||
|
||||
interface ITimeAgoCellProps {
|
||||
export interface ITimeAgoCellProps {
|
||||
value?: string | number | Date | null;
|
||||
column?: ColumnInstance;
|
||||
live?: boolean;
|
||||
emptyText?: string;
|
||||
title?: (date: string) => string;
|
||||
title?: (date?: string) => ReactNode;
|
||||
dateFormat?: (value: string | number | Date, locale: string) => string;
|
||||
}
|
||||
|
||||
@ -20,18 +20,19 @@ export const TimeAgoCell: FC<ITimeAgoCellProps> = ({
|
||||
column,
|
||||
live = false,
|
||||
emptyText = 'Never',
|
||||
title = (date) => (column ? `${column.Header}: ${date}` : date),
|
||||
title = (date) =>
|
||||
date ? (column ? `${column.Header}: ${date}` : date) : '',
|
||||
dateFormat = formatDateYMD,
|
||||
}) => {
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
if (!value) return <TextCell>{emptyText}</TextCell>;
|
||||
|
||||
const date = dateFormat(value, locationSettings.locale);
|
||||
const tooltip = value
|
||||
? title(dateFormat(value, locationSettings.locale))
|
||||
: title();
|
||||
|
||||
return (
|
||||
<TextCell>
|
||||
<Tooltip title={title(date)} arrow>
|
||||
<Tooltip title={tooltip} arrow>
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
@ -42,7 +43,11 @@ export const TimeAgoCell: FC<ITimeAgoCellProps> = ({
|
||||
variant='body2'
|
||||
data-loading
|
||||
>
|
||||
<TimeAgo date={value} refresh={live} />
|
||||
{value ? (
|
||||
<TimeAgo date={value} refresh={live} />
|
||||
) : (
|
||||
emptyText
|
||||
)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</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 { useUiFlag } from 'hooks/useUiFlag.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 }) => ({
|
||||
marginBottom: theme.spacing(3),
|
||||
@ -25,13 +26,12 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
const StyledAlertContent = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledUl = styled('ul')(({ theme }) => ({
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
}));
|
||||
const StyledHeader = styled('div')({
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const UnknownFlagsTable = () => {
|
||||
const { unknownFlags, loading } = useUnknownFlags();
|
||||
@ -46,13 +46,14 @@ export const UnknownFlagsTable = () => {
|
||||
{
|
||||
Header: 'Flag name',
|
||||
accessor: 'name',
|
||||
minWidth: 200,
|
||||
minWidth: 100,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Application',
|
||||
accessor: 'appName',
|
||||
searchable: true,
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
Header: 'Environment',
|
||||
@ -60,27 +61,47 @@ export const UnknownFlagsTable = () => {
|
||||
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',
|
||||
Cell: ({ value, column }) => (
|
||||
Cell: ({ value }) => (
|
||||
<TimeAgoCell
|
||||
value={value}
|
||||
column={column}
|
||||
title={(date) => `Reported: ${date}`}
|
||||
dateFormat={formatDateYMDHMS}
|
||||
/>
|
||||
),
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
Header: (
|
||||
<StyledHeader>
|
||||
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: ({
|
||||
row: { original: unknownFlag },
|
||||
}: { row: { original: UnknownFlag } }) => (
|
||||
<UnknownFlagsActionsCell unknownFlag={unknownFlag} />
|
||||
}: {
|
||||
row: { original: UnknownFlag };
|
||||
}) => (
|
||||
<UnknownFlagsSeenInUnleashCell
|
||||
unknownFlag={unknownFlag}
|
||||
dateFormat={formatDateYMDHMS}
|
||||
/>
|
||||
),
|
||||
width: 100,
|
||||
disableSortBy: true,
|
||||
width: 150,
|
||||
},
|
||||
],
|
||||
[],
|
||||
@ -121,7 +142,7 @@ export const UnknownFlagsTable = () => {
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Unknown flags (${rows.length})`}
|
||||
title={`Unknown flag report (${rows.length})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
@ -154,36 +175,34 @@ export const UnknownFlagsTable = () => {
|
||||
<StyledAlert severity='info'>
|
||||
<StyledAlertContent>
|
||||
<p>
|
||||
<strong>Unknown flags</strong> are feature flags that
|
||||
your SDKs tried to evaluate but which Unleash doesn't
|
||||
recognize. Tracking them helps you catch typos, remove
|
||||
outdated flags, and keep your code and configuration in
|
||||
sync. These can include:
|
||||
</p>
|
||||
|
||||
<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.
|
||||
<b>
|
||||
Clean up unknown flags to keep your code and
|
||||
configuration in sync
|
||||
</b>
|
||||
<br />
|
||||
Unknown flags are feature flags that your SDKs tried to
|
||||
evaluate but which Unleash doesn't recognize.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We display up to 1,000 unknown flag reports from the
|
||||
last 24 hours. Older reports are automatically pruned.
|
||||
<b>Unknown flags can include:</b>
|
||||
<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>
|
||||
</StyledAlertContent>
|
||||
</StyledAlert>
|
||||
|
@ -10,6 +10,7 @@ export type UnknownFlag = {
|
||||
appName: string;
|
||||
seenAt: Date;
|
||||
environment: string;
|
||||
lastEventAt: Date;
|
||||
};
|
||||
|
||||
type UnknownFlagsResponse = {
|
||||
|
@ -2,6 +2,7 @@ import type { Db } from '../../../db/db.js';
|
||||
import type { Logger, LogProvider } from '../../../logger.js';
|
||||
|
||||
const TABLE = 'unknown_flags';
|
||||
const TABLE_EVENTS = 'events';
|
||||
const MAX_INSERT_BATCH_SIZE = 100;
|
||||
|
||||
export type UnknownFlag = {
|
||||
@ -9,6 +10,7 @@ export type UnknownFlag = {
|
||||
appName: string;
|
||||
seenAt: Date;
|
||||
environment: string;
|
||||
lastEventAt?: Date;
|
||||
};
|
||||
|
||||
export type QueryParams = {
|
||||
@ -64,11 +66,17 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
|
||||
}
|
||||
|
||||
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
|
||||
let query = this.db(TABLE).select(
|
||||
'name',
|
||||
'app_name',
|
||||
'seen_at',
|
||||
'environment',
|
||||
let query = this.db(`${TABLE} AS uf`).select(
|
||||
'uf.name',
|
||||
'uf.app_name',
|
||||
'uf.seen_at',
|
||||
'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) {
|
||||
@ -84,8 +92,9 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
|
||||
return rows.map((row) => ({
|
||||
name: row.name,
|
||||
appName: row.app_name,
|
||||
seenAt: new Date(row.seen_at),
|
||||
seenAt: row.seen_at,
|
||||
environment: row.environment,
|
||||
lastEventAt: row.last_event_at,
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,14 @@ export const unknownFlagSchema = {
|
||||
'The environment in which the unknown flag was reported.',
|
||||
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: {},
|
||||
} as const;
|
||||
|
Loading…
Reference in New Issue
Block a user