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:
parent
9d2df8aa75
commit
3b6613360c
@ -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),
|
||||
|
@ -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/*',
|
||||
|
186
frontend/src/component/unknownFlags/UnknownFlagsTable.tsx
Normal file
186
frontend/src/component/unknownFlags/UnknownFlagsTable.tsx
Normal 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 “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No unknown flags reported in the last 7 days.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
50
frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts
Normal file
50
frontend/src/component/unknownFlags/hooks/useUnknownFlags.ts
Normal 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());
|
||||
};
|
@ -93,6 +93,7 @@ export type UiFlags = {
|
||||
crDiffView?: boolean;
|
||||
changeRequestApproverEmails?: boolean;
|
||||
eventGrouping?: boolean;
|
||||
reportUnknownFlags?: boolean;
|
||||
};
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user