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",
|
"title": "Archived flags",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"menu": {},
|
||||||
|
"path": "/unknown-flags",
|
||||||
|
"title": "Unknown flags",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component": {
|
"component": {
|
||||||
"$$typeof": Symbol(react.lazy),
|
"$$typeof": Symbol(react.lazy),
|
||||||
|
@ -52,6 +52,7 @@ import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseM
|
|||||||
import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate';
|
import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate';
|
||||||
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
|
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
|
||||||
import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js';
|
import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js';
|
||||||
|
import { UnknownFlagsTable } from 'component/unknownFlags/UnknownFlagsTable';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -471,6 +472,15 @@ export const routes: IRoute[] = [
|
|||||||
menu: {},
|
menu: {},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Unknown flags
|
||||||
|
{
|
||||||
|
path: '/unknown-flags',
|
||||||
|
title: 'Unknown flags',
|
||||||
|
component: UnknownFlagsTable,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
|
},
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
{
|
{
|
||||||
path: '/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;
|
crDiffView?: boolean;
|
||||||
changeRequestApproverEmails?: boolean;
|
changeRequestApproverEmails?: boolean;
|
||||||
eventGrouping?: boolean;
|
eventGrouping?: boolean;
|
||||||
|
reportUnknownFlags?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
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());
|
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;
|
if (!limit) return flags;
|
||||||
return flags.slice(0, limit);
|
return flags.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
@ -63,6 +63,12 @@ export default class UnknownFlagsController extends Controller {
|
|||||||
}
|
}
|
||||||
const unknownFlags = await this.unknownFlagsService.getAll({
|
const unknownFlags = await this.unknownFlagsService.getAll({
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
column: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
|
@ -5,7 +5,7 @@ import type {
|
|||||||
IUnleashConfig,
|
IUnleashConfig,
|
||||||
} from '../../../types/index.js';
|
} from '../../../types/index.js';
|
||||||
import type { IUnleashStores } 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 {
|
export class UnknownFlagsService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -50,9 +50,9 @@ export class UnknownFlagsService {
|
|||||||
this.unknownFlagsCache.clear();
|
this.unknownFlagsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll({ limit }: { limit?: number }): Promise<UnknownFlag[]> {
|
async getAll(queryParams?: QueryParams): Promise<UnknownFlag[]> {
|
||||||
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return [];
|
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return [];
|
||||||
return this.unknownFlagsStore.getAll({ limit });
|
return this.unknownFlagsStore.getAll(queryParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(hoursAgo: number) {
|
async clear(hoursAgo: number) {
|
||||||
|
@ -11,6 +11,10 @@ export type UnknownFlag = {
|
|||||||
|
|
||||||
export type QueryParams = {
|
export type QueryParams = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
orderBy?: {
|
||||||
|
column: string;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IUnknownFlagsStore {
|
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(
|
let query = this.db(TABLE).select(
|
||||||
'name',
|
'name',
|
||||||
'app_name',
|
'app_name',
|
||||||
@ -51,6 +55,10 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
|
|||||||
'environment',
|
'environment',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (orderBy) {
|
||||||
|
query = query.orderBy(orderBy);
|
||||||
|
}
|
||||||
|
|
||||||
if (limit) {
|
if (limit) {
|
||||||
query = query.limit(limit);
|
query = query.limit(limit);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user