From 3b6613360c32e9b2b7ce8fb767cca71406d7ef8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 8 Jul 2025 17:51:03 +0100 Subject: [PATCH] chore: unknown flags UI (#10332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://linear.app/unleash/issue/2-3682/add-unknown-flags-page-with-table-and-description Adds a `/unknown-flags` page with a table of unknown flag reports and a short description of what this is. It’s only accessible via direct URL for now (if the flag is enabled), but it allows us to share the list with some customers. image --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../__snapshots__/routes.test.tsx.snap | 7 + frontend/src/component/menu/routes.ts | 10 + .../unknownFlags/UnknownFlagsTable.tsx | 186 ++++++++++++++++++ .../unknownFlags/hooks/useUnknownFlags.ts | 50 +++++ frontend/src/interfaces/uiConfig.ts | 1 + .../unknown-flags/fake-unknown-flags-store.ts | 11 +- .../unknown-flags/unknown-flags-controller.ts | 6 + .../unknown-flags/unknown-flags-service.ts | 6 +- .../unknown-flags/unknown-flags-store.ts | 10 +- 9 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 frontend/src/component/unknownFlags/UnknownFlagsTable.tsx create mode 100644 frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 510aea1231..0e353125b6 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -457,6 +457,13 @@ exports[`returns all baseRoutes 1`] = ` "title": "Archived flags", "type": "protected", }, + { + "component": [Function], + "menu": {}, + "path": "/unknown-flags", + "title": "Unknown flags", + "type": "protected", + }, { "component": { "$$typeof": Symbol(react.lazy), diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 6c9edf3b27..c66eb80b1d 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -52,6 +52,7 @@ import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseM import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate'; import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate'; import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js'; +import { UnknownFlagsTable } from 'component/unknownFlags/UnknownFlagsTable'; export const routes: IRoute[] = [ // Splash @@ -471,6 +472,15 @@ export const routes: IRoute[] = [ menu: {}, }, + // Unknown flags + { + path: '/unknown-flags', + title: 'Unknown flags', + component: UnknownFlagsTable, + type: 'protected', + menu: {}, + }, + // Admin { path: '/admin/*', diff --git a/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx b/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx new file mode 100644 index 0000000000..4cb7b93f24 --- /dev/null +++ b/frontend/src/component/unknownFlags/UnknownFlagsTable.tsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from 'react'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Alert, styled, useMediaQuery } from '@mui/material'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { Search } from 'component/common/Search/Search'; +import { useSearch } from 'hooks/useSearch'; +import { useUnknownFlags } from './hooks/useUnknownFlags.js'; +import theme from 'themes/theme.js'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell.js'; +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'; + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(3), +})); + +const StyledUl = styled('ul')(({ theme }) => ({ + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), +})); + +export const UnknownFlagsTable = () => { + const { unknownFlags, loading } = useUnknownFlags(); + const unknownFlagsEnabled = useUiFlag('reportUnknownFlags'); + + const [searchValue, setSearchValue] = useState(''); + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + Header: 'Flag name', + accessor: 'name', + minWidth: 200, + searchable: true, + }, + { + Header: 'Application', + accessor: 'appName', + searchable: true, + }, + { + Header: 'Environment', + accessor: 'environment', + searchable: true, + }, + { + Header: 'Last seen', + accessor: 'seenAt', + Cell: ({ value }: { value: Date }) => ( + + ), + }, + ], + [], + ); + + const [initialState] = useState({ + sortBy: [{ id: 'name', desc: false }], + }); + + const { data, getSearchText } = useSearch( + columns, + searchValue, + unknownFlags, + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns: columns as any, + data, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: HighlightCell, + }, + }, + useSortBy, + useFlexLayout, + ); + + if (!unknownFlagsEnabled) return ; + + return ( + + + + + + } + /> + + } + > + + } + /> + + } + > + +

+ 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. +
  • +
    + +

    + We display up to 1,000 unknown flag reports from the last 7 + days. Older flags are automatically pruned. +

    +
    + + + + + 0} + show={ + + No unknown flags found matching “ + {searchValue} + ” + + } + elseShow={ + + No unknown flags reported in the last 7 days. + + } + /> + } + /> +
    + ); +}; diff --git a/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts b/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts new file mode 100644 index 0000000000..d8f71c8717 --- /dev/null +++ b/frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import { useUiFlag } from 'hooks/useUiFlag.js'; +import { useConditionalSWR } from 'hooks/api/getters/useConditionalSWR/useConditionalSWR.js'; +import handleErrorResponses from 'hooks/api/getters/httpErrorResponseHandler'; +import type { SWRConfiguration } from 'swr'; + +export type UnknownFlag = { + name: string; + appName: string; + seenAt: Date; + environment: string; +}; + +type UnknownFlagsResponse = { + unknownFlags: UnknownFlag[]; +}; + +const ENDPOINT = 'api/admin/metrics/unknown-flags'; +const DEFAULT_DATA: UnknownFlagsResponse = { + unknownFlags: [], +}; + +export const useUnknownFlags = (options?: SWRConfiguration) => { + const reportUnknownFlagsEnabled = useUiFlag('reportUnknownFlags'); + + const { data, error, mutate } = useConditionalSWR( + reportUnknownFlagsEnabled, + DEFAULT_DATA, + formatApiPath(ENDPOINT), + fetcher, + options, + ); + + return useMemo( + () => ({ + unknownFlags: (data || DEFAULT_DATA).unknownFlags, + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Unknown Flags')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 7e641a1506..250f8be9b6 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -93,6 +93,7 @@ export type UiFlags = { crDiffView?: boolean; changeRequestApproverEmails?: boolean; eventGrouping?: boolean; + reportUnknownFlags?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts b/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts index db1f8160f4..5284212477 100644 --- a/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts +++ b/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts @@ -18,8 +18,17 @@ export class FakeUnknownFlagsStore implements IUnknownFlagsStore { } } - async getAll({ limit }: QueryParams = {}): Promise { + async getAll({ limit, orderBy }: QueryParams = {}): Promise { const flags = Array.from(this.unknownFlagMap.values()); + if (orderBy) { + flags.sort((a, b) => { + for (const { column, order } of orderBy) { + if (a[column] < b[column]) return order === 'asc' ? -1 : 1; + if (a[column] > b[column]) return order === 'asc' ? 1 : -1; + } + return 0; + }); + } if (!limit) return flags; return flags.slice(0, limit); } diff --git a/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts b/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts index 495829c186..51cce606d6 100644 --- a/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts @@ -63,6 +63,12 @@ export default class UnknownFlagsController extends Controller { } const unknownFlags = await this.unknownFlagsService.getAll({ limit: 1000, + orderBy: [ + { + column: 'name', + order: 'asc', + }, + ], }); this.openApiService.respondWithValidation( diff --git a/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts b/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts index 99d6192244..fe5ea911a3 100644 --- a/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts @@ -5,7 +5,7 @@ import type { IUnleashConfig, } from '../../../types/index.js'; import type { IUnleashStores } from '../../../types/index.js'; -import type { UnknownFlag } from './unknown-flags-store.js'; +import type { QueryParams, UnknownFlag } from './unknown-flags-store.js'; export class UnknownFlagsService { private logger: Logger; @@ -50,9 +50,9 @@ export class UnknownFlagsService { this.unknownFlagsCache.clear(); } - async getAll({ limit }: { limit?: number }): Promise { + async getAll(queryParams?: QueryParams): Promise { if (!this.flagResolver.isEnabled('reportUnknownFlags')) return []; - return this.unknownFlagsStore.getAll({ limit }); + return this.unknownFlagsStore.getAll(queryParams); } async clear(hoursAgo: number) { 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 db9e50a616..14be585d2a 100644 --- a/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts @@ -11,6 +11,10 @@ export type UnknownFlag = { export type QueryParams = { limit?: number; + orderBy?: { + column: string; + order: 'asc' | 'desc'; + }[]; }; export interface IUnknownFlagsStore { @@ -43,7 +47,7 @@ export class UnknownFlagsStore implements IUnknownFlagsStore { } } - async getAll({ limit }: QueryParams = {}): Promise { + async getAll({ limit, orderBy }: QueryParams = {}): Promise { let query = this.db(TABLE).select( 'name', 'app_name', @@ -51,6 +55,10 @@ export class UnknownFlagsStore implements IUnknownFlagsStore { 'environment', ); + if (orderBy) { + query = query.orderBy(orderBy); + } + if (limit) { query = query.limit(limit); }