Skip to content

Commit 5375214

Browse files
committed
✨ [FFL-2597] add feature flags tab with OAuth + catalog browsing
Adds a Feature Flags tab to the developer extension: authenticate with Datadog via OAuth (authorization_code + PKCE, tokens in session storage) and browse your feature-flag catalog with search/type/tag filters. Overrides are added in the follow-up FFL-2596. First of a stacked series.
1 parent b7f2b6e commit 5375214

17 files changed

Lines changed: 869 additions & 1 deletion

developer-extension/src/common/extension.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,7 @@ export interface Settings {
5757
logsConfigurationOverride: object | null
5858
debugMode: boolean
5959
datadogMode: boolean
60+
// Datadog site used for the feature-flag OAuth flow + catalog fetch (e.g. datadoghq.com,
61+
// datad0g.com for staging).
62+
flagsSite: string
6063
}

developer-extension/src/common/panelTabConstants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const enum PanelTabs {
33
Infos = 'infos',
44
Settings = 'settings',
55
Replay = 'replay',
6+
Flags = 'flags',
67
}
78

89
export const DEFAULT_PANEL_TAB = PanelTabs.Events

developer-extension/src/panel/components/panel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SettingsTab } from './tabs/settingsTab'
1313
import { InfosTab } from './tabs/infosTab'
1414
import { EventsTab, DEFAULT_COLUMNS } from './tabs/eventsTab'
1515
import { ReplayTab } from './tabs/replayTab'
16+
import { FlagsTab } from './tabs/flagsTab'
1617

1718
import * as classes from './panel.module.css'
1819

@@ -53,6 +54,9 @@ export function Panel() {
5354
<Tabs.Tab value={PanelTabs.Replay}>
5455
<Text>Live replay</Text>
5556
</Tabs.Tab>
57+
<Tabs.Tab value={PanelTabs.Flags}>
58+
<Text>Feature Flags</Text>
59+
</Tabs.Tab>
5660
<Tabs.Tab
5761
value={PanelTabs.Settings}
5862
rightSection={
@@ -92,6 +96,9 @@ export function Panel() {
9296
<Tabs.Panel value={PanelTabs.Replay} className={classes.tab}>
9397
<ReplayTab />
9498
</Tabs.Panel>
99+
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
100+
<FlagsTab />
101+
</Tabs.Panel>
95102
<Tabs.Panel value={PanelTabs.Settings} className={classes.tab}>
96103
<SettingsTab />
97104
</Tabs.Panel>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Anchor, Badge, Button, Center, Group, Stack, Text, TextInput } from '@mantine/core'
2+
import React, { useState } from 'react'
3+
import { useSettings } from '../../../hooks/useSettings'
4+
import type { FlagAuthState } from './useFlagAuth'
5+
6+
export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
7+
const [advancedOpen, setAdvancedOpen] = useState(false)
8+
9+
return (
10+
<Center h="100%" className="dd-privacy-allow">
11+
<Stack align="center" gap="md" maw={460} px="md">
12+
<Text size="xl" fw={600} ta="center">
13+
Authenticate with Datadog to Override Feature Flags
14+
</Text>
15+
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
16+
Sign in to Datadog
17+
</Button>
18+
{auth.error && (
19+
<Text c="red" size="xs" ta="center">
20+
{auth.error}
21+
</Text>
22+
)}
23+
24+
<Anchor size="xs" c="dimmed" onClick={() => setAdvancedOpen((open) => !open)}>
25+
{advancedOpen ? '− Hide advanced' : 'Advanced: site'}
26+
</Anchor>
27+
{advancedOpen && (
28+
<Stack gap="sm" style={{ width: '100%' }}>
29+
<SiteField />
30+
</Stack>
31+
)}
32+
</Stack>
33+
</Center>
34+
)
35+
}
36+
37+
export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
38+
return (
39+
<Group justify="space-between">
40+
<Group gap="xs">
41+
<Badge color="green" variant="light">
42+
Connected via OAuth
43+
</Badge>
44+
<Text c="dimmed" size="xs">
45+
{auth.site}
46+
</Text>
47+
</Group>
48+
<Button size="compact-xs" variant="subtle" color="gray" onClick={auth.disconnect}>
49+
Disconnect
50+
</Button>
51+
</Group>
52+
)
53+
}
54+
55+
function SiteField() {
56+
const [{ flagsSite }, setSetting] = useSettings()
57+
58+
return (
59+
<TextInput
60+
label="Datadog site"
61+
placeholder="datadoghq.com"
62+
description="Use datad0g.com for staging."
63+
value={flagsSite}
64+
onChange={(event) => setSetting('flagsSite', event.currentTarget.value)}
65+
size="xs"
66+
/>
67+
)
68+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { FlagOverrideType } from './flagTypeConstants'
2+
import { getFlagsApiHost } from './oauth'
3+
4+
export interface CatalogVariant {
5+
name: string
6+
value: boolean | string | number | object
7+
}
8+
9+
export interface CatalogFlag {
10+
key: string
11+
// Human-friendly display name; falls back to the key when the API doesn't provide one.
12+
name: string
13+
type: FlagOverrideType
14+
variants: CatalogVariant[]
15+
tags: string[]
16+
}
17+
18+
interface RawFeatureFlagVariant {
19+
name: string
20+
value: string
21+
}
22+
23+
interface RawFeatureFlagAttributes {
24+
key: string
25+
name?: string
26+
value_type: FlagOverrideType
27+
variants?: RawFeatureFlagVariant[]
28+
tags?: string[]
29+
}
30+
31+
interface RawFeatureFlagResource {
32+
attributes: RawFeatureFlagAttributes
33+
}
34+
35+
interface RawFeatureFlagsResponse {
36+
data: RawFeatureFlagResource[]
37+
}
38+
39+
// Variant values come back from the API as strings regardless of the flag's declared type. Falls
40+
// back to the raw string on unparseable input so one malformed variant can't blow up the mapping
41+
// of the entire catalog.
42+
function parseVariantValue(type: FlagOverrideType, rawValue: string): CatalogVariant['value'] {
43+
switch (type) {
44+
case 'BOOLEAN':
45+
return rawValue === 'true'
46+
case 'INTEGER': {
47+
const parsed = parseInt(rawValue, 10)
48+
return Number.isNaN(parsed) ? rawValue : parsed
49+
}
50+
case 'NUMERIC': {
51+
const parsed = parseFloat(rawValue)
52+
return Number.isNaN(parsed) ? rawValue : parsed
53+
}
54+
case 'JSON':
55+
try {
56+
return JSON.parse(rawValue) as object
57+
} catch {
58+
return rawValue
59+
}
60+
case 'STRING':
61+
return rawValue
62+
}
63+
}
64+
65+
// The endpoint paginates via limit/offset and enforces its own max page size, so a page can
66+
// come back shorter than PAGE_LIMIT even when more results remain — only an empty page means
67+
// we've reached the end. Advance offset by what actually came back, not by PAGE_LIMIT.
68+
const PAGE_LIMIT = 100
69+
// Safety bound so a backend that ignores `offset` (returns the same page forever) can't spin an
70+
// unbounded loop. 500 pages × 100 = 50k flags, far beyond any real catalog.
71+
const MAX_PAGES = 500
72+
73+
/**
74+
* Fetches the full flag catalog using an OAuth access token, via the FFE UI endpoint
75+
* (GET /api/ui/ffe/feature-flags). OAuth is the only supported auth path (see oauth.ts).
76+
*/
77+
export async function fetchFlagCatalogWithToken(token: string, site: string): Promise<CatalogFlag[]> {
78+
const host = getFlagsApiHost(site)
79+
const resources: RawFeatureFlagResource[] = []
80+
let offset = 0
81+
82+
for (let page = 0; page < MAX_PAGES; page++) {
83+
const url = new URL(`https://${host}/api/ui/ffe/feature-flags`)
84+
url.searchParams.set('limit', String(PAGE_LIMIT))
85+
url.searchParams.set('offset', String(offset))
86+
87+
const response = await fetch(url.toString(), {
88+
headers: {
89+
Authorization: `Bearer ${token}`,
90+
},
91+
})
92+
93+
if (!response.ok) {
94+
throw new Error(`Failed to fetch flag catalog: ${response.status} ${response.statusText}`)
95+
}
96+
97+
const body = (await response.json()) as RawFeatureFlagsResponse
98+
// Tolerate a response that omits/mistypes `data` rather than throwing on `.length`/spread.
99+
const pageResources = Array.isArray(body?.data) ? body.data : []
100+
resources.push(...pageResources)
101+
102+
if (pageResources.length === 0) {
103+
break
104+
}
105+
offset += pageResources.length
106+
}
107+
108+
return mapResources(resources)
109+
}
110+
111+
function mapResources(resources: RawFeatureFlagResource[]): CatalogFlag[] {
112+
// Dedupe by key. The endpoint's offset pagination can return the same flag on more than one
113+
// page, which would otherwise render as duplicate rows AND collide React keys (`key={flag.key}`),
114+
// breaking variant clicks, search, and clear-all. Keep the first occurrence of each key.
115+
const byKey = new Map<string, CatalogFlag>()
116+
for (const { attributes } of resources) {
117+
if (byKey.has(attributes.key)) {
118+
continue
119+
}
120+
byKey.set(attributes.key, {
121+
key: attributes.key,
122+
name: attributes.name || attributes.key,
123+
type: attributes.value_type,
124+
variants: (attributes.variants ?? []).map((variant) => ({
125+
name: variant.name,
126+
value: parseVariantValue(attributes.value_type, variant.value),
127+
})),
128+
tags: attributes.tags ?? [],
129+
})
130+
}
131+
return Array.from(byKey.values())
132+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ActionIcon, Badge, Box, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
2+
import { IconCopy } from '@tabler/icons-react'
3+
import React from 'react'
4+
import type { CatalogFlag } from './flagCatalog'
5+
import type { FlagCatalogState } from './useFlagCatalog'
6+
7+
export function FlagCatalogBody({
8+
catalog,
9+
flags,
10+
totalFiltered,
11+
totalFlags,
12+
}: {
13+
catalog: FlagCatalogState
14+
flags: CatalogFlag[]
15+
totalFiltered: number
16+
totalFlags: number
17+
}) {
18+
if (catalog.loading) {
19+
return (
20+
<Group justify="center" py="xl">
21+
<Loader size="sm" />
22+
</Group>
23+
)
24+
}
25+
26+
if (catalog.error) {
27+
return <Text c="red">Failed to load catalog: {catalog.error}</Text>
28+
}
29+
30+
return (
31+
<>
32+
<Text c="dimmed" size="xs">
33+
{totalFiltered} of {totalFlags} flags
34+
</Text>
35+
<Space h="xs" />
36+
<Box style={{ border: '1px solid var(--mantine-color-gray-2)', borderRadius: 'var(--mantine-radius-sm)' }}>
37+
{flags.length === 0 ? (
38+
<Text c="dimmed" p="md">
39+
No flags match.
40+
</Text>
41+
) : (
42+
flags.map((flag) => <FlagRow key={flag.key} flag={flag} />)
43+
)}
44+
</Box>
45+
</>
46+
)
47+
}
48+
49+
function FlagRow({ flag }: { flag: CatalogFlag }) {
50+
return (
51+
<Group
52+
justify="space-between"
53+
wrap="nowrap"
54+
align="center"
55+
px="sm"
56+
py="xs"
57+
style={{ borderBottom: '1px solid var(--mantine-color-gray-1)' }}
58+
>
59+
<Box style={{ minWidth: 0, flex: 1 }}>
60+
<Text size="sm" fw={600} truncate>
61+
{flag.name}
62+
</Text>
63+
<FlagKey value={flag.key} />
64+
</Box>
65+
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
66+
{flag.variants.length === 0 ? (
67+
<Text c="dimmed" size="xs">
68+
no variants
69+
</Text>
70+
) : (
71+
flag.variants.map((variant) => (
72+
<Badge key={variant.name} variant="light" color="gray" title={formatValue(variant.value)}>
73+
{variant.name}
74+
</Badge>
75+
))
76+
)}
77+
</Group>
78+
</Group>
79+
)
80+
}
81+
82+
function FlagKey({ value }: { value: string }) {
83+
return (
84+
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
85+
<Code
86+
style={{
87+
flex: '0 1 auto',
88+
minWidth: 0,
89+
overflow: 'hidden',
90+
textOverflow: 'ellipsis',
91+
whiteSpace: 'nowrap',
92+
}}
93+
>
94+
{value}
95+
</Code>
96+
<CopyButton value={value}>
97+
{({ copied, copy }) => (
98+
<Tooltip label={copied ? 'Copied' : 'Copy key'} withArrow>
99+
<ActionIcon size="xs" variant="subtle" color="gray" onClick={copy} style={{ flexShrink: 0 }}>
100+
<IconCopy size={12} />
101+
</ActionIcon>
102+
</Tooltip>
103+
)}
104+
</CopyButton>
105+
</Group>
106+
)
107+
}
108+
109+
function formatValue(value: unknown): string {
110+
return typeof value === 'string' ? value : JSON.stringify(value)
111+
}

0 commit comments

Comments
 (0)