1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

chore: unknown flags UI (#10332)

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.

<img width="1026" alt="image"
src="https://github.com/user-attachments/assets/feee88bb-bbce-4871-98d7-f76f95076ee2"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Nuno Góis 2025-07-08 17:51:03 +01:00 committed by GitHub
parent 9d2df8aa75
commit 3b6613360c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 282 additions and 5 deletions

View File

@ -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),

View File

@ -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/*',

View File

@ -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 }) => (
<TimeAgoCell value={value} dateFormat={formatDateYMDHMS} />
),
},
],
[],
);
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 <NotFound />;
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Unknown flags (${rows.length})`}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
<PageHeader.Divider />
</>
}
/>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<StyledAlert severity='info'>
<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>
We display up to 1,000 unknown flag reports from the last 7
days. Older flags are automatically pruned.
</p>
</StyledAlert>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No unknown flags found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No unknown flags reported in the last 7 days.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};

View File

@ -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<UnknownFlagsResponse>(
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());
};

View File

@ -93,6 +93,7 @@ export type UiFlags = {
crDiffView?: boolean;
changeRequestApproverEmails?: boolean;
eventGrouping?: boolean;
reportUnknownFlags?: boolean;
};
export interface IVersionInfo {

View File

@ -18,8 +18,17 @@ export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
}
}
async getAll({ limit }: QueryParams = {}): Promise<UnknownFlag[]> {
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
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);
}

View File

@ -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(

View File

@ -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<UnknownFlag[]> {
async getAll(queryParams?: QueryParams): Promise<UnknownFlag[]> {
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return [];
return this.unknownFlagsStore.getAll({ limit });
return this.unknownFlagsStore.getAll(queryParams);
}
async clear(hoursAgo: number) {

View File

@ -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<UnknownFlag[]> {
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
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);
}