From 885d3e18173ea1ce601a3bee8be35a8b5025bcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 22 Aug 2025 14:04:01 +0100 Subject: [PATCH] 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". image --- .../Table/cells/TimeAgoCell/TimeAgoCell.tsx | 23 ++-- .../unknownFlags/UnknownFlagsActionsCell.tsx | 33 ------ .../UnknownFlagsSeenInUnleashCell.tsx | 37 ++++++ .../unknownFlags/UnknownFlagsTable.tsx | 109 ++++++++++-------- .../unknownFlags/hooks/useUnknownFlags.ts | 1 + .../unknown-flags/unknown-flags-store.ts | 21 +++- src/lib/openapi/spec/unknown-flag-schema.ts | 8 ++ 7 files changed, 139 insertions(+), 93 deletions(-) delete mode 100644 frontend/src/component/unknownFlags/UnknownFlagsActionsCell.tsx create mode 100644 frontend/src/component/unknownFlags/UnknownFlagsSeenInUnleashCell.tsx diff --git a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx index 88800de52c..e020171f58 100644 --- a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx +++ b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx @@ -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 = ({ 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 {emptyText}; - - const date = dateFormat(value, locationSettings.locale); + const tooltip = value + ? title(dateFormat(value, locationSettings.locale)) + : title(); return ( - + = ({ variant='body2' data-loading > - + {value ? ( + + ) : ( + emptyText + )} diff --git a/frontend/src/component/unknownFlags/UnknownFlagsActionsCell.tsx b/frontend/src/component/unknownFlags/UnknownFlagsActionsCell.tsx deleted file mode 100644 index 86ab729be0..0000000000 --- a/frontend/src/component/unknownFlags/UnknownFlagsActionsCell.tsx +++ /dev/null @@ -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) => ( - - - - - -); diff --git a/frontend/src/component/unknownFlags/UnknownFlagsSeenInUnleashCell.tsx b/frontend/src/component/unknownFlags/UnknownFlagsSeenInUnleashCell.tsx new file mode 100644 index 0000000000..69324a6b18 --- /dev/null +++ b/frontend/src/component/unknownFlags/UnknownFlagsSeenInUnleashCell.tsx @@ -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 = ; + + if (value && isAdmin) { + return ( + + {TimeAgo} + + ); + } + + return TimeAgo; +}; diff --git a/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx b/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx index 9bda4c41a0..5e8eda0125 100644 --- a/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx +++ b/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx @@ -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: ( + + Reported + + + ), accessor: 'seenAt', - Cell: ({ value, column }) => ( + Cell: ({ value }) => ( `Reported: ${date}`} dateFormat={formatDateYMDHMS} /> ), + width: 150, }, { - Header: 'Actions', - id: 'Actions', - align: 'center', + Header: ( + + Last event + + + ), + accessor: 'lastEventAt', Cell: ({ row: { original: unknownFlag }, - }: { row: { original: UnknownFlag } }) => ( - + }: { + row: { original: UnknownFlag }; + }) => ( + ), - width: 100, - disableSortBy: true, + width: 150, }, ], [], @@ -121,7 +142,7 @@ export const UnknownFlagsTable = () => { isLoading={loading} header={ {

- Unknown flags 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: -

- - -
  • - Missing flags: typos or flags referenced in - code that don't exist in Unleash. -
  • -
  • - Invalid flags: flags with malformed or - unexpected names, unsupported by Unleash. -
  • -
    - -

    - Each row in the table represents an{' '} - unknown flag report, which is a unique - combination of flag name, application, - and environment. The same flag name may appear - multiple times if it's been seen in different - applications or environments. + + Clean up unknown flags to keep your code and + configuration in sync + +
    + Unknown flags are feature flags that your SDKs tried to + evaluate but which Unleash doesn't recognize.

    - We display up to 1,000 unknown flag reports from the - last 24 hours. Older reports are automatically pruned. + Unknown flags can include: +

      +
    • + Missing flags: typos or flags referenced in code + that don't exist in Unleash. +
    • +
    • + Invalid flags: flags with malformed or + unexpected names, unsupported by Unleash. +
    • +
    +

    + +

    + Why do I see the same flag name multiple times? +
    + The same flag name will appear multiple times if it's + been seen in different applications or environments.

    diff --git a/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts b/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts index d8f71c8717..1923a4276c 100644 --- a/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts +++ b/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts @@ -10,6 +10,7 @@ export type UnknownFlag = { appName: string; seenAt: Date; environment: string; + lastEventAt: Date; }; type UnknownFlagsResponse = { diff --git a/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts b/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts index 86836bf817..f665f75a3d 100644 --- a/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts @@ -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 { - 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, })); } diff --git a/src/lib/openapi/spec/unknown-flag-schema.ts b/src/lib/openapi/spec/unknown-flag-schema.ts index c921c2c695..cb6394cdb6 100644 --- a/src/lib/openapi/spec/unknown-flag-schema.ts +++ b/src/lib/openapi/spec/unknown-flag-schema.ts @@ -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;