diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..972f64b
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,24 @@
+{
+ "name": "Flagsmith Backstage Plugin",
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:22",
+ "postCreateCommand": "yarn install",
+ "forwardPorts": [3000],
+ "portsAttributes": {
+ "3000": {
+ "label": "Plugin Preview",
+ "onAutoForward": "openBrowser"
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint"
+ ],
+ "settings": {
+ "editor.formatOnSave": true
+ }
+ }
+ },
+ "postStartCommand": "echo '🚀 Run: yarn start' && echo '📍 Then open the forwarded port 3000'"
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f9d27e6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,65 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Run lint
+ run: yarn lint
+
+ typecheck:
+ name: TypeScript
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: TypeScript check
+ run: yarn tsc
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Build package
+ run: yarn build:all
diff --git a/.gitignore b/.gitignore
index 5e91c8b..e6001d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,12 +9,15 @@ node_modules/
# Production
/build
/dist
+/dist-demo
+/dist-types
/lib
*.tsbuildinfo
# Misc
.DS_Store
.env
+app-config.local.yaml
.env.local
.env.development.local
.env.test.local
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..2312dc5
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/README.md b/README.md
index 1d5df18..a69f164 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,161 @@
-# flagsmith
+# Flagsmith Plugin for Backstage
-Welcome to the Flagsmith plugin!
+Integrate [Flagsmith](https://flagsmith.com) feature flags into your Backstage instance.
-This plugins:
+## Features
-- Adds a 'Feature Flags' tab on component pages.
-- Provides 2 Cards that can be added to component Overview pages.
+- **Feature Flags Tab** - View all feature flags for a service directly in the entity page
+- **Overview Card** - Quick summary of flags and their states
+- **Usage Card** - Display Flagsmith usage metrics
-## Getting started
+## Installation
-Currently, it is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
+### 1. Install the plugin
-Add the following annotations to a component to link it to a flagsmith project, replacing with your Project and Organization IDs:
+```bash
+# From your Backstage root directory
+yarn --cwd packages/app add @flagsmith/backstage-plugin
+```
+
+### 2. Configure the Backstage proxy
+
+Add to your `app-config.yaml` (or `app-config.local.yaml` for local development):
+
+```yaml
+proxy:
+ endpoints:
+ '/flagsmith':
+ target: 'https://api.flagsmith.com/api/v1'
+ headers:
+ Authorization: Api-Key ${FLAGSMITH_API_TOKEN}
+```
+> **Note:** Use an environment variable for the API token in production. Never commit tokens to version control.
+
+For self-hosted Flagsmith, change the target URL:
+
+```yaml
+proxy:
+ endpoints:
+ '/flagsmith':
+ target: 'https://your-flagsmith-instance.com/api/v1'
+ headers:
+ Authorization: Api-Key ${FLAGSMITH_API_TOKEN}
```
-annotations:
- flagsmith.com/project-id: "00000"
- flagsmith.com/org-id: "00000" # Optional, defaults to first org
+
+### 3. Add the Feature Flags tab to entity pages
+
+In `packages/app/src/components/catalog/EntityPage.tsx`:
+
+```typescript
+import { FlagsTab } from '@flagsmith/backstage-plugin';
+
+// Add to your entity page layout (e.g., serviceEntityPage)
+
+
+
+```
+
+### 4. (Optional) Add cards to the Overview page
+
+```typescript
+import {
+ FlagsmithOverviewCard,
+ FlagsmithUsageCard,
+} from '@flagsmith/backstage-plugin';
+
+// Add to your entity overview page
+
+
+
+
+
+
+```
+
+### 5. Annotate your entities
+
+Add Flagsmith annotations to your `catalog-info.yaml`:
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: my-service
+ annotations:
+ flagsmith.com/project-id: '12345'
+ flagsmith.com/org-id: '67890' # Optional - defaults to first organization
+spec:
+ type: service
+ owner: team-a
```
-Configure your credentials by adding the following to app-config.yaml (or your local override app-config.local.yaml):
+## Getting your Flagsmith credentials
+
+1. Log in to your [Flagsmith dashboard](https://app.flagsmith.com)
+2. Go to **Organisation Settings** > **API Keys**
+3. Create or copy your **Admin API Key**
+4. Find your **Project ID** and **Organisation ID** in the URL or project settings
+
+## Development
+
+### Prerequisites
+
+- Node.js 22+ (Node 24 has known ESM compatibility issues with Backstage)
+- Yarn
+- A Backstage application for testing
+
+### Local Development Setup
+
+1. Clone the repository:
+
+ ```bash
+ git clone https://github.com/Flagsmith/flagsmith-backstage-plugin.git
+ cd flagsmith-backstage-plugin
+ ```
+
+2. Install dependencies:
+
+ ```bash
+ yarn install
+ ```
+
+3. To test in a Backstage app, copy or link the plugin to your Backstage workspace's `plugins/` directory and add it as a workspace dependency.
+
+4. Create `app-config.local.yaml` with your Flagsmith credentials (this file is gitignored).
+
+5. Run the Backstage app:
+ ```bash
+ yarn start
+ ```
+
+### Available Scripts
+
+| Command | Description |
+| ------------ | ---------------------------- |
+| `yarn start` | Start the development server |
+| `yarn build` | Build for production |
+| `yarn test` | Run tests |
+| `yarn lint` | Lint the codebase |
+
+### Project Structure
```
-# Backstage override configuration for your local development environment
-flagsmith:
- apiUrl: https://api.flagsmith.com
- apiToken: yourApiToken
+src/
+├── components/ # React components
+│ ├── FlagsTab.tsx
+│ ├── FlagsmithOverviewCard.tsx
+│ └── FlagsmithUsageCard.tsx
+├── api/ # API client (uses Backstage proxy)
+│ └── FlagsmithClient.ts
+├── plugin.ts # Frontend plugin definition
+└── index.ts # Package exports
```
+
+## Contributing
+
+Contributions are welcome! Please open an issue or submit a pull request.
+
+## License
+
+Apache-2.0
diff --git a/app-config.yaml b/app-config.yaml
new file mode 100644
index 0000000..6a7f807
--- /dev/null
+++ b/app-config.yaml
@@ -0,0 +1,14 @@
+app:
+ title: Flagsmith Plugin Dev
+ baseUrl: http://localhost:3000
+
+backend:
+ baseUrl: http://localhost:7007
+ listen:
+ port: 7007
+
+# Mock proxy endpoint - requests are intercepted by MSW
+proxy:
+ endpoints:
+ '/flagsmith':
+ target: 'http://localhost:7007'
diff --git a/dev/index.tsx b/dev/index.tsx
index 016a84b..ef2c4c7 100644
--- a/dev/index.tsx
+++ b/dev/index.tsx
@@ -1,6 +1,66 @@
import { createDevApp } from '@backstage/dev-utils';
-import { flagsmithPlugin } from '../src/plugin';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import { Entity } from '@backstage/catalog-model';
+import { setupWorker } from 'msw';
+import { PropsWithChildren } from 'react';
+import { flagsmithPlugin, FlagsTab, FlagsmithOverviewCard, FlagsmithUsageCard } from '../src';
+import { handlers } from './mockHandlers';
+
+// Start MSW worker for API mocking
+const worker = setupWorker(...handlers);
+worker.start({
+ onUnhandledRequest: 'bypass',
+});
+
+// Mock entity with Flagsmith annotations
+const mockEntity: Entity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ name: 'demo-service',
+ description: 'A demo service with Flagsmith feature flags integration',
+ annotations: {
+ 'flagsmith.com/project-id': '31465',
+ 'flagsmith.com/org-id': '24242',
+ },
+ },
+ spec: {
+ type: 'service',
+ lifecycle: 'production',
+ owner: 'guests',
+ },
+};
+
+// Wrapper component to provide entity context
+const EntityWrapper = ({ children }: PropsWithChildren<{}>) => (
+ {children}
+);
createDevApp()
.registerPlugin(flagsmithPlugin)
+ .addPage({
+ element: (
+
+
+
+ ),
+ title: 'Feature Flags',
+ path: '/flagsmith',
+ })
+ .addPage({
+ element: (
+
+
+
+ ),
+ title: 'Overview Cards',
+ path: '/flagsmith-cards',
+ })
.render();
diff --git a/dev/mockHandlers.ts b/dev/mockHandlers.ts
new file mode 100644
index 0000000..238e39b
--- /dev/null
+++ b/dev/mockHandlers.ts
@@ -0,0 +1,258 @@
+import { rest } from 'msw';
+
+// Mock data that represents realistic Flagsmith responses
+const mockProject = {
+ id: 31465,
+ name: 'Demo Project',
+ organisation: 24242,
+ created_date: '2024-01-15T10:00:00Z',
+};
+
+const mockEnvironments = [
+ {
+ id: 101,
+ name: 'Development',
+ api_key: 'dev_api_key_123',
+ project: 31465,
+ },
+ {
+ id: 102,
+ name: 'Staging',
+ api_key: 'staging_api_key_456',
+ project: 31465,
+ },
+ {
+ id: 103,
+ name: 'Production',
+ api_key: 'prod_api_key_789',
+ project: 31465,
+ },
+];
+
+const mockFeatures = [
+ {
+ id: 1001,
+ name: 'dark_mode',
+ description: 'Enable dark mode theme for the application',
+ created_date: '2024-02-01T09:00:00Z',
+ project: 31465,
+ default_enabled: true,
+ type: 'FLAG',
+ is_archived: false,
+ tags: ['ui', 'theme'],
+ owners: [{ id: 1, name: 'John Doe', email: 'john@example.com' }],
+ },
+ {
+ id: 1002,
+ name: 'new_checkout_flow',
+ description: 'A/B test for the new checkout experience',
+ created_date: '2024-03-10T14:30:00Z',
+ project: 31465,
+ default_enabled: false,
+ type: 'FLAG',
+ is_archived: false,
+ tags: ['checkout', 'experiment'],
+ owners: [{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }],
+ },
+ {
+ id: 1003,
+ name: 'api_rate_limit',
+ description: 'API rate limiting configuration',
+ created_date: '2024-01-20T11:15:00Z',
+ project: 31465,
+ default_enabled: true,
+ type: 'CONFIG',
+ is_archived: false,
+ tags: ['api', 'performance'],
+ owners: [],
+ },
+ {
+ id: 1004,
+ name: 'beta_features',
+ description: 'Enable beta features for selected users',
+ created_date: '2024-04-05T16:45:00Z',
+ project: 31465,
+ default_enabled: false,
+ type: 'FLAG',
+ is_archived: false,
+ tags: ['beta'],
+ owners: [{ id: 1, name: 'John Doe', email: 'john@example.com' }],
+ },
+ {
+ id: 1005,
+ name: 'maintenance_mode',
+ description: 'Put the application in maintenance mode',
+ created_date: '2024-02-28T08:00:00Z',
+ project: 31465,
+ default_enabled: false,
+ type: 'FLAG',
+ is_archived: false,
+ tags: ['ops'],
+ owners: [],
+ },
+];
+
+const mockFeatureVersions: Record = {
+ 1001: [
+ {
+ uuid: 'v1-dark-mode-uuid',
+ is_live: true,
+ live_from: '2024-02-01T10:00:00Z',
+ published: true,
+ published_by: 'John Doe',
+ },
+ ],
+ 1002: [
+ {
+ uuid: 'v1-checkout-uuid',
+ is_live: true,
+ live_from: '2024-03-15T09:00:00Z',
+ published: true,
+ published_by: 'Jane Smith',
+ },
+ ],
+ 1003: [
+ {
+ uuid: 'v1-rate-limit-uuid',
+ is_live: true,
+ live_from: '2024-01-21T00:00:00Z',
+ published: true,
+ published_by: 'System',
+ },
+ ],
+ 1004: [
+ {
+ uuid: 'v1-beta-uuid',
+ is_live: true,
+ live_from: '2024-04-10T12:00:00Z',
+ published: true,
+ published_by: 'John Doe',
+ },
+ ],
+ 1005: [
+ {
+ uuid: 'v1-maintenance-uuid',
+ is_live: true,
+ live_from: '2024-03-01T00:00:00Z',
+ published: true,
+ published_by: 'Admin',
+ },
+ ],
+};
+
+const mockFeatureStates: Record = {
+ 'v1-dark-mode-uuid': [
+ { id: 2001, enabled: true, feature_segment: null, feature_state_value: null },
+ { id: 2002, enabled: true, feature_segment: 501, feature_state_value: null }, // Segment override
+ ],
+ 'v1-checkout-uuid': [
+ { id: 2003, enabled: false, feature_segment: null, feature_state_value: null },
+ { id: 2004, enabled: true, feature_segment: 502, feature_state_value: null }, // Beta users segment
+ ],
+ 'v1-rate-limit-uuid': [
+ { id: 2005, enabled: true, feature_segment: null, feature_state_value: '1000' },
+ ],
+ 'v1-beta-uuid': [
+ { id: 2006, enabled: false, feature_segment: null, feature_state_value: null },
+ { id: 2007, enabled: true, feature_segment: 503, feature_state_value: null }, // Beta testers
+ { id: 2008, enabled: true, feature_segment: 504, feature_state_value: null }, // Internal users
+ ],
+ 'v1-maintenance-uuid': [
+ { id: 2009, enabled: false, feature_segment: null, feature_state_value: null },
+ ],
+};
+
+const mockUsageData = [
+ {
+ flags: 15420,
+ identities: 3250,
+ traits: 8900,
+ environment_document: 450,
+ day: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+ {
+ flags: 16800,
+ identities: 3400,
+ traits: 9200,
+ environment_document: 480,
+ day: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+ {
+ flags: 14200,
+ identities: 3100,
+ traits: 8500,
+ environment_document: 420,
+ day: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+ {
+ flags: 17500,
+ identities: 3600,
+ traits: 9800,
+ environment_document: 510,
+ day: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+ {
+ flags: 18200,
+ identities: 3750,
+ traits: 10100,
+ environment_document: 530,
+ day: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+ {
+ flags: 16900,
+ identities: 3500,
+ traits: 9400,
+ environment_document: 490,
+ day: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+ {
+ flags: 15800,
+ identities: 3300,
+ traits: 9000,
+ environment_document: 460,
+ day: new Date().toISOString().split('T')[0],
+ labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null },
+ },
+];
+
+export const handlers = [
+ // Get project
+ rest.get('*/proxy/flagsmith/projects/:projectId/', (req, res, ctx) => {
+ return res(ctx.json(mockProject));
+ }),
+
+ // Get project environments
+ rest.get('*/proxy/flagsmith/projects/:projectId/environments/', (req, res, ctx) => {
+ return res(ctx.json({ results: mockEnvironments }));
+ }),
+
+ // Get project features
+ rest.get('*/proxy/flagsmith/projects/:projectId/features/', (req, res, ctx) => {
+ return res(ctx.json({ results: mockFeatures }));
+ }),
+
+ // Get feature versions (lazy loading)
+ rest.get('*/proxy/flagsmith/environments/:envId/features/:featureId/versions/', (req, res, ctx) => {
+ const featureId = parseInt(req.params.featureId as string, 10);
+ const versions = mockFeatureVersions[featureId] || [];
+ return res(ctx.json({ results: versions }));
+ }),
+
+ // Get feature states (lazy loading)
+ rest.get('*/proxy/flagsmith/environments/:envId/features/:featureId/versions/:versionUuid/featurestates/', (req, res, ctx) => {
+ const versionUuid = req.params.versionUuid as string;
+ const states = mockFeatureStates[versionUuid] || [];
+ return res(ctx.json(states));
+ }),
+
+ // Get usage data
+ rest.get('*/proxy/flagsmith/organisations/:orgId/usage-data/', (req, res, ctx) => {
+ return res(ctx.json(mockUsageData));
+ }),
+];
diff --git a/package.json b/package.json
index 5323669..1434174 100644
--- a/package.json
+++ b/package.json
@@ -19,31 +19,33 @@
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
+ "lint:fix": "backstage-cli package lint --fix",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
- "postpack": "backstage-cli package postpack"
+ "postpack": "backstage-cli package postpack",
+ "tsc": "tsc || test -f dist-types/src/index.d.ts",
+ "build:all": "yarn tsc && backstage-cli package build",
+ "prepare": "husky"
+ },
+ "lint-staged": {
+ "*.{ts,tsx}": [
+ "backstage-cli package lint --fix"
+ ]
},
"dependencies": {
- "@backstage/backend-defaults": "^0.2.0",
- "@backstage/backend-plugin-api": "^0.6.0",
- "@backstage/config": "^1.1.0",
"@backstage/core-components": "^0.18.2",
"@backstage/core-plugin-api": "^1.11.1",
"@backstage/plugin-catalog-react": "^1.13.3",
- "@backstage/theme": "^0.7.0",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
- "@material-ui/lab": "^4.0.0-alpha.61",
- "express": "^4.17.1",
- "express-promise-router": "^4.1.0",
- "react-use": "^17.2.4",
"recharts": "^2.5.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
},
"devDependencies": {
+ "@backstage/catalog-model": "^1.7.6",
"@backstage/cli": "^0.34.4",
"@backstage/core-app-api": "^1.19.1",
"@backstage/dev-utils": "^1.1.15",
@@ -51,10 +53,17 @@
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
+ "husky": "^9.1.7",
+ "lint-staged": "^16.2.7",
"msw": "^1.0.0",
- "react": "^16.13.1 || ^17.0.0 || ^18.0.0"
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "react-router-dom": "^6.0.0"
},
"files": [
"dist"
- ]
+ ],
+ "msw": {
+ "workerDirectory": "public"
+ }
}
diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js
new file mode 100644
index 0000000..1f45c4c
--- /dev/null
+++ b/public/mockServiceWorker.js
@@ -0,0 +1,303 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker (1.3.5).
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ * - Please do NOT serve this file on production.
+ */
+
+const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
+const activeClientIds = new Set()
+
+self.addEventListener('install', function () {
+ self.skipWaiting()
+})
+
+self.addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('message', async function (event) {
+ const clientId = event.source.id
+
+ if (!clientId || !self.clients) {
+ return
+ }
+
+ const client = await self.clients.get(clientId)
+
+ if (!client) {
+ return
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: INTEGRITY_CHECKSUM,
+ })
+ break
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: true,
+ })
+ break
+ }
+
+ case 'MOCK_DEACTIVATE': {
+ activeClientIds.delete(clientId)
+ break
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId
+ })
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister()
+ }
+
+ break
+ }
+ }
+})
+
+self.addEventListener('fetch', function (event) {
+ const { request } = event
+ const accept = request.headers.get('accept') || ''
+
+ // Bypass server-sent events.
+ if (accept.includes('text/event-stream')) {
+ return
+ }
+
+ // Bypass navigation requests.
+ if (request.mode === 'navigate') {
+ return
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
+ return
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been deleted (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return
+ }
+
+ // Generate unique request ID.
+ const requestId = Math.random().toString(16).slice(2)
+
+ event.respondWith(
+ handleRequest(event, requestId).catch((error) => {
+ if (error.name === 'NetworkError') {
+ console.warn(
+ '[MSW] Successfully emulated a network error for the "%s %s" request.',
+ request.method,
+ request.url,
+ )
+ return
+ }
+
+ // At this point, any exception indicates an issue with the original request/response.
+ console.error(
+ `\
+[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
+ request.method,
+ request.url,
+ `${error.name}: ${error.message}`,
+ )
+ }),
+ )
+})
+
+async function handleRequest(event, requestId) {
+ const client = await resolveMainClient(event)
+ const response = await getResponse(event, client, requestId)
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ ;(async function () {
+ const clonedResponse = response.clone()
+ sendToClient(client, {
+ type: 'RESPONSE',
+ payload: {
+ requestId,
+ type: clonedResponse.type,
+ ok: clonedResponse.ok,
+ status: clonedResponse.status,
+ statusText: clonedResponse.statusText,
+ body:
+ clonedResponse.body === null ? null : await clonedResponse.text(),
+ headers: Object.fromEntries(clonedResponse.headers.entries()),
+ redirected: clonedResponse.redirected,
+ },
+ })
+ })()
+ }
+
+ return response
+}
+
+// Resolve the main client for the given event.
+// Client that issues a request doesn't necessarily equal the client
+// that registered the worker. It's with the latter the worker should
+// communicate with during the response resolving phase.
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId)
+
+ if (client?.frameType === 'top-level') {
+ return client
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible'
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id)
+ })
+}
+
+async function getResponse(event, client, requestId) {
+ const { request } = event
+ const clonedRequest = request.clone()
+
+ function passthrough() {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const headers = Object.fromEntries(clonedRequest.headers.entries())
+
+ // Remove MSW-specific request headers so the bypassed requests
+ // comply with the server's CORS preflight check.
+ // Operate with the headers as an object because request "Headers"
+ // are immutable.
+ delete headers['x-msw-bypass']
+
+ return fetch(clonedRequest, { headers })
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough()
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough()
+ }
+
+ // Bypass requests with the explicit bypass header.
+ // Such requests can be issued by "ctx.fetch()".
+ if (request.headers.get('x-msw-bypass') === 'true') {
+ return passthrough()
+ }
+
+ // Notify the client that a request has been intercepted.
+ const clientMessage = await sendToClient(client, {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ url: request.url,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ mode: request.mode,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.text(),
+ bodyUsed: request.bodyUsed,
+ keepalive: request.keepalive,
+ },
+ })
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data)
+ }
+
+ case 'MOCK_NOT_FOUND': {
+ return passthrough()
+ }
+
+ case 'NETWORK_ERROR': {
+ const { name, message } = clientMessage.data
+ const networkError = new Error(message)
+ networkError.name = name
+
+ // Rejecting a "respondWith" promise emulates a network error.
+ throw networkError
+ }
+ }
+
+ return passthrough()
+}
+
+function sendToClient(client, message) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel()
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
+ }
+
+ resolve(event.data)
+ }
+
+ client.postMessage(message, [channel.port2])
+ })
+}
+
+function sleep(timeMs) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, timeMs)
+ })
+}
+
+async function respondWithMock(response) {
+ await sleep(response.delay)
+ return new Response(response.body, response)
+}
diff --git a/src/api/FlagsmithAdminService.ts b/src/api/FlagsmithAdminService.ts
deleted file mode 100644
index c593f9a..0000000
--- a/src/api/FlagsmithAdminService.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-export interface FlagsmithConfig {
- // We'll figure out how to pass the API functions here
-}
-
-export class FlagsmithAdminService {
- constructor(private config: FlagsmithConfig) {}
-
- async getOrganizations() {
- // Call the flagsmith_admin_api_list_organizations function
- }
-
- async getProjectsInOrg(orgId: number) {
- // Call flagsmith_admin_api_list_projects_in_organization
- }
-
- async getProjectFeatures(projectId: number) {
- // Call flagsmith_admin_api_list_project_features
- }
-
- async getProjectEnvironments(projectId: number) {
- // Call flagsmith_admin_api_list_project_environments
- }
-}
\ No newline at end of file
diff --git a/src/api/FlagsmithClient.ts b/src/api/FlagsmithClient.ts
index a84e8e8..59f9b7e 100644
--- a/src/api/FlagsmithClient.ts
+++ b/src/api/FlagsmithClient.ts
@@ -4,7 +4,6 @@ export interface FlagsmithOrganization {
id: number;
name: string;
created_date: string;
- // Add more fields as needed
}
export interface FlagsmithProject {
@@ -12,7 +11,6 @@ export interface FlagsmithProject {
name: string;
organisation: number;
created_date: string;
- // Add more fields as needed
}
export interface FlagsmithEnvironment {
@@ -28,18 +26,20 @@ export interface FlagsmithFeature {
description?: string;
created_date: string;
project: number;
- environment_state: Array<{
+ environment_state?: Array<{
id: number;
enabled: boolean;
- }>;
+ feature_segment?: number | null;
+ }> | null;
num_segment_overrides?: number | null;
num_identity_overrides?: number | null;
- live_version: {
+ live_version?: {
is_live: boolean;
live_from?: string | null;
published: boolean;
published_by?: string | null;
- };
+ uuid?: string;
+ } | null;
owners?: Array<{
id: number;
name: string;
@@ -52,6 +52,27 @@ export interface FlagsmithFeature {
is_archived?: boolean;
}
+export interface FlagsmithFeatureVersion {
+ uuid: string;
+ is_live: boolean;
+ live_from?: string | null;
+ published: boolean;
+ published_by?: string | null;
+}
+
+export interface FlagsmithFeatureState {
+ id: number;
+ enabled: boolean;
+ feature_segment?: number | null;
+ feature_state_value?: string | null;
+}
+
+export interface FlagsmithFeatureDetails {
+ liveVersion: FlagsmithFeatureVersion | null;
+ featureState: FlagsmithFeatureState[] | null;
+ segmentOverrides: number;
+}
+
export interface FlagsmithUsageData {
flags: number | null;
identities: number;
@@ -72,72 +93,71 @@ export class FlagsmithClient {
) {}
private async getBaseUrl(): Promise {
- return await this.discoveryApi.getBaseUrl('flagsmith');
+ const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
+ return `${proxyUrl}/flagsmith`;
}
async getOrganizations(): Promise {
const baseUrl = await this.getBaseUrl();
- const response = await this.fetchApi.fetch(`${baseUrl}/organizations`);
-
+ const response = await this.fetchApi.fetch(`${baseUrl}/organisations/`);
+
if (!response.ok) {
throw new Error(`Failed to fetch organizations: ${response.statusText}`);
}
-
+
const data = await response.json();
- return data.results || data; // Handle paginated vs non-paginated responses
+ return data.results || data;
}
async getProjectsInOrg(orgId: number): Promise {
const baseUrl = await this.getBaseUrl();
- const response = await this.fetchApi.fetch(`${baseUrl}/organizations/${orgId}/projects`);
-
+ const response = await this.fetchApi.fetch(
+ `${baseUrl}/organisations/${orgId}/projects/`,
+ );
+
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.statusText}`);
}
-
+
const data = await response.json();
return data.results || data;
}
async getProjectFeatures(projectId: string): Promise {
const baseUrl = await this.getBaseUrl();
- const response = await this.fetchApi.fetch(`${baseUrl}/projects/${projectId}/features`);
-
+ const response = await this.fetchApi.fetch(
+ `${baseUrl}/projects/${projectId}/features/`,
+ );
+
if (!response.ok) {
throw new Error(`Failed to fetch features: ${response.statusText}`);
}
-
- const data = await response.json();
- return data.results || data;
- }
- async getEnvironmentFeatures(environmentId: number, projectId: string): Promise {
- const baseUrl = await this.getBaseUrl();
- const response = await this.fetchApi.fetch(`${baseUrl}/projects/${projectId}/environments/${environmentId}/features`);
-
- if (!response.ok) {
- throw new Error(`Failed to fetch environment features: ${response.statusText}`);
- }
-
const data = await response.json();
return data.results || data;
}
- async getProjectEnvironments(projectId: number): Promise {
+ async getProjectEnvironments(
+ projectId: number,
+ ): Promise {
const baseUrl = await this.getBaseUrl();
- const response = await this.fetchApi.fetch(`${baseUrl}/projects/${projectId}/environments`);
-
+ const response = await this.fetchApi.fetch(
+ `${baseUrl}/projects/${projectId}/environments/`,
+ );
+
if (!response.ok) {
throw new Error(`Failed to fetch environments: ${response.statusText}`);
}
-
+
const data = await response.json();
return data.results || data;
}
async getProject(projectId: number): Promise {
const baseUrl = await this.getBaseUrl();
- const response = await this.fetchApi.fetch(`${baseUrl}/projects/${projectId}`);
+ const response = await this.fetchApi.fetch(
+ `${baseUrl}/projects/${projectId}/`,
+ );
if (!response.ok) {
throw new Error(`Failed to fetch project: ${response.statusText}`);
@@ -146,11 +166,14 @@ export class FlagsmithClient {
return await response.json();
}
- async getUsageData(orgId: number, projectId?: number, period: string = '30_day_period'): Promise {
+ async getUsageData(
+ orgId: number,
+ projectId?: number,
+ ): Promise {
const baseUrl = await this.getBaseUrl();
- let url = `${baseUrl}/organizations/${orgId}/usage-data?period=${period}`;
+ let url = `${baseUrl}/organisations/${orgId}/usage-data/`;
if (projectId) {
- url += `&project_id=${projectId}`;
+ url += `?project_id=${projectId}`;
}
const response = await this.fetchApi.fetch(url);
@@ -161,4 +184,70 @@ export class FlagsmithClient {
return await response.json();
}
-}
\ No newline at end of file
+
+ // Lazy loading methods for feature details
+ async getFeatureVersions(
+ environmentId: number,
+ featureId: number,
+ ): Promise {
+ const baseUrl = await this.getBaseUrl();
+ const response = await this.fetchApi.fetch(
+ `${baseUrl}/environments/${environmentId}/features/${featureId}/versions/`,
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch feature versions: ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+ return data.results || data;
+ }
+
+ async getFeatureStates(
+ environmentId: number,
+ featureId: number,
+ versionUuid: string,
+ ): Promise {
+ const baseUrl = await this.getBaseUrl();
+ const response = await this.fetchApi.fetch(
+ `${baseUrl}/environments/${environmentId}/features/${featureId}/versions/${versionUuid}/featurestates/`,
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch feature states: ${response.statusText}`);
+ }
+
+ return await response.json();
+ }
+
+ // Helper to load full feature details (called on accordion expand)
+ async getFeatureDetails(
+ environmentId: number,
+ featureId: number,
+ ): Promise {
+ const versions = await this.getFeatureVersions(environmentId, featureId);
+ const liveVersion = versions.find(v => v.is_live) || null;
+
+ let featureState: FlagsmithFeatureState[] | null = null;
+ let segmentOverrides = 0;
+
+ if (liveVersion) {
+ featureState = await this.getFeatureStates(
+ environmentId,
+ featureId,
+ liveVersion.uuid,
+ );
+ segmentOverrides = (featureState || []).filter(
+ s => s.feature_segment !== null,
+ ).length;
+ }
+
+ return {
+ liveVersion,
+ featureState,
+ segmentOverrides,
+ };
+ }
+}
diff --git a/src/components/FlagsTab.tsx b/src/components/FlagsTab.tsx
index 22934ee..1810251 100644
--- a/src/components/FlagsTab.tsx
+++ b/src/components/FlagsTab.tsx
@@ -18,28 +18,74 @@ import {
IconButton,
Collapse,
Chip,
- Badge,
} from '@material-ui/core';
-import { KeyboardArrowDown, KeyboardArrowRight } from '@material-ui/icons';
+import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown';
+import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
import { useEntity } from '@backstage/plugin-catalog-react';
-import { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
-import { FlagsmithClient, FlagsmithEnvironment, FlagsmithFeature } from '../api/FlagsmithClient';
+import {
+ useApi,
+ discoveryApiRef,
+ fetchApiRef,
+} from '@backstage/core-plugin-api';
+import {
+ FlagsmithClient,
+ FlagsmithEnvironment,
+ FlagsmithFeature,
+ FlagsmithFeatureDetails,
+} from '../api/FlagsmithClient';
interface ExpandableRowProps {
feature: FlagsmithFeature;
+ client: FlagsmithClient;
+ environmentId: number;
}
-const ExpandableRow = ({ feature }: ExpandableRowProps) => {
+const ExpandableRow = ({
+ feature,
+ client,
+ environmentId,
+}: ExpandableRowProps) => {
const [open, setOpen] = useState(false);
const [envStatesOpen, setEnvStatesOpen] = useState(false);
+ const [details, setDetails] = useState(null);
+ const [loadingDetails, setLoadingDetails] = useState(false);
+ const [detailsError, setDetailsError] = useState(null);
+
+ const handleToggle = async () => {
+ const newOpen = !open;
+ setOpen(newOpen);
+
+ // Load details on first expand
+ if (newOpen && !details && !loadingDetails) {
+ setLoadingDetails(true);
+ setDetailsError(null);
+ try {
+ const featureDetails = await client.getFeatureDetails(
+ environmentId,
+ feature.id,
+ );
+ setDetails(featureDetails);
+ } catch (err) {
+ setDetailsError(
+ err instanceof Error ? err.message : 'Failed to load details',
+ );
+ } finally {
+ setLoadingDetails(false);
+ }
+ }
+ };
- console.log('Rendering feature row for:', feature);
+ // Use details if loaded, otherwise fall back to feature data
+ const liveVersion = details?.liveVersion || feature.live_version;
+ const environmentState = details?.featureState || feature.environment_state;
+ const segmentOverrides =
+ details?.segmentOverrides ?? feature.num_segment_overrides ?? 0;
return (
<>
- setOpen(!open)}>
+
{open ? : }
@@ -54,20 +100,14 @@ const ExpandableRow = ({ feature }: ExpandableRowProps) => {
- 0 ? feature.num_segment_overrides : null}
- color="secondary"
- overlap="rectangular"
- >
-
-
+
- - {/* Placeholder for value */}
+ -
@@ -79,200 +119,313 @@ const ExpandableRow = ({ feature }: ExpandableRowProps) => {
- {/* Main Info Row - 4 Columns */}
-
- {/* Column 1: Active Version */}
- {feature.live_version && (
-
-
- Active Version
-
-
-
- Status:{' '}
- {feature.live_version.is_live ? 'Active' : 'Inactive'}
-
-
- Published: {feature.live_version.published ? 'Yes' : 'No'}
-
- {feature.live_version.live_from && (
-
- Active From: {new Date(feature.live_version.live_from).toLocaleString()}
-
- )}
-
- Published By: User ID {feature.live_version.published_by}
-
-
-
- )}
-
- {/* Column 2: Overview */}
-
-
- Overview
+ {loadingDetails && (
+
+
+
+ Loading feature details...
-
-
- ID: {feature.id}
-
-
- Type: {feature.type}
-
-
- Default Enabled: {feature.default_enabled ? 'Yes' : 'No'}
-
-
- Archived: {feature.is_archived ? 'Yes' : 'No'}
-
- {feature.is_server_key_only && (
-
-
-
- )}
-
-
-
- {/* Column 3: Owners */}
- {feature.owners && feature.owners.length > 0 && (
-
-
- Owners
-
-
- {feature.owners.map((owner: any) => (
-
-
- {owner.first_name} {owner.last_name}
+
+ )}
+ {!loadingDetails && detailsError && (
+
+ {detailsError}
+
+ )}
+ {!loadingDetails && !detailsError && (
+ <>
+ {/* Main Info Row - 4 Columns */}
+
+ {/* Column 1: Active Version */}
+ {liveVersion && (
+
+
+ Active Version
+
+
+
+ Status:{' '}
+ {liveVersion.is_live ? 'Active' : 'Inactive'}
-
- {owner.email}
+
+ Published:{' '}
+ {liveVersion.published ? 'Yes' : 'No'}
-
- Last login: {new Date(owner.last_login).toLocaleString()}
+ {liveVersion.live_from && (
+
+ Active From:{' '}
+ {new Date(liveVersion.live_from).toLocaleString()}
+
+ )}
+
+ Published By: User ID{' '}
+ {liveVersion.published_by}
- ))}
-
-
- )}
-
- {/* Column 4: Overrides */}
-
-
- Overrides
-
-
-
- Segment Overrides: {feature.num_segment_overrides || 0}
-
- {feature.num_identity_overrides !== null && (
-
- Identity Overrides: {feature.num_identity_overrides}
-
+
)}
-
-
- {/* Tags Row (if exists) */}
- {feature.tags && feature.tags.length > 0 && (
-
-
- Tags
-
-
- {feature.tags.map((tag: any, index: number) => (
-
- ))}
-
-
- )}
-
+ {/* Column 2: Overview */}
+
+
+ Overview
+
+
+
+ ID: {feature.id}
+
+
+ Type: {feature.type}
+
+
+ Default Enabled:{' '}
+ {feature.default_enabled ? 'Yes' : 'No'}
+
+
+ Archived:{' '}
+ {feature.is_archived ? 'Yes' : 'No'}
+
+ {feature.is_server_key_only && (
+
+
+
+ )}
+
+
- {/* Environment States - Collapsible Section */}
- {feature.environment_state && feature.environment_state.length > 0 && (
-
- setEnvStatesOpen(!envStatesOpen)} style={{ cursor: 'pointer' }}>
-
- {envStatesOpen ? : }
-
-
- Environment States ({feature.environment_state.length})
-
-
-
-
- {feature.environment_state.map((state: any) => (
- 0 && (
+
+
-
-
-
- {state.feature_segment && (
-
- )}
+ Owners
+
+
+ {feature.owners.map((owner: any) => (
+
- Env ID: {state.environment}
+
+ {owner.first_name} {owner.last_name}
+
-
-
- Updated: {new Date(state.updated_at).toLocaleString()}
-
-
-
- {/* Feature State Value - Only if not null */}
- {state.feature_state_value && (
- state.feature_state_value.string_value !== null ||
- state.feature_state_value.integer_value !== null ||
- state.feature_state_value.boolean_value !== null
- ) && (
-
- {state.feature_state_value.string_value !== null && (
-
- Value: {state.feature_state_value.string_value}
-
- )}
- {state.feature_state_value.integer_value !== null && (
-
- Value: {state.feature_state_value.integer_value}
-
- )}
- {state.feature_state_value.boolean_value !== null && (
-
- Value: {state.feature_state_value.boolean_value.toString()}
+
+ {owner.email}
+
+ {owner.last_login && (
+
+ Last login:{' '}
+ {new Date(owner.last_login).toLocaleString()}
)}
+ ))}
+
+
+ )}
+
+ {/* Column 4: Overrides */}
+
+
+ Overrides
+
+
+
+ Segment Overrides: {segmentOverrides}
+
+ {feature.num_identity_overrides !== null &&
+ feature.num_identity_overrides !== undefined && (
+
+ Identity Overrides:{' '}
+ {feature.num_identity_overrides}
+
)}
+
+
- {/* Segment Information */}
- {state.feature_segment && (
-
-
- Segment ID: {state.feature_segment.segment} | Priority: {state.feature_segment.priority}
-
-
+ {/* Tags Row (if exists) */}
+ {feature.tags && feature.tags.length > 0 && (
+
+
+ Tags
+
+
+ {feature.tags.map((tag: any, index: number) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Environment States - Collapsible Section */}
+ {environmentState && environmentState.length > 0 && (
+
+ setEnvStatesOpen(!envStatesOpen)}
+ style={{ cursor: 'pointer' }}
+ >
+
+ {envStatesOpen ? (
+
+ ) : (
+
)}
+
+
+ Environment States ({environmentState.length})
+
+
+
+
+ {environmentState.map((state: any) => (
+
+
+
+
+ {state.feature_segment && (
+
+ )}
+ {state.environment && (
+
+ Env ID: {state.environment}
+
+ )}
+
+ {state.updated_at && (
+
+ Updated:{' '}
+ {new Date(state.updated_at).toLocaleString()}
+
+ )}
+
+
+ {/* Feature State Value */}
+ {state.feature_state_value && (
+
+ {state.feature_state_value.string_value !== null &&
+ state.feature_state_value.string_value !== undefined && (
+
+ Value:{' '}
+ {state.feature_state_value.string_value}
+
+ )}
+ {state.feature_state_value.integer_value !== null &&
+ state.feature_state_value.integer_value !== undefined && (
+
+ Value:{' '}
+ {state.feature_state_value.integer_value}
+
+ )}
+ {state.feature_state_value.boolean_value !== null &&
+ state.feature_state_value.boolean_value !== undefined && (
+
+ Value:{' '}
+ {String(state.feature_state_value.boolean_value)}
+
+ )}
+
+ )}
+
+ {/* Segment Information */}
+ {state.feature_segment && (
+
+
+ Segment ID:{' '}
+ {state.feature_segment.segment} |{' '}
+ Priority:{' '}
+ {state.feature_segment.priority}
+
+
+ )}
+
+ ))}
- ))}
+
-
-
+ )}
+ >
)}
@@ -286,14 +439,17 @@ export const FlagsTab = () => {
const { entity } = useEntity();
const discoveryApi = useApi(discoveryApiRef);
const fetchApi = useApi(fetchApiRef);
-
+
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [projectInfo, setProjectInfo] = useState(null);
const [environments, setEnvironments] = useState([]);
- const [selectedEnvironment, setSelectedEnvironment] = useState(null);
+ const [selectedEnvironment, setSelectedEnvironment] = useState(
+ null,
+ );
const [features, setFeatures] = useState([]);
const [featuresLoading, setFeaturesLoading] = useState(false);
+ const [client] = useState(() => new FlagsmithClient(discoveryApi, fetchApi));
// Get project ID from entity annotations
const projectId = entity.metadata.annotations?.['flagsmith.com/project-id'];
@@ -307,22 +463,18 @@ export const FlagsTab = () => {
const fetchData = async () => {
try {
- const client = new FlagsmithClient(discoveryApi, fetchApi);
-
// Fetch project info
- const project = await client.getProject(parseInt(projectId));
+ const project = await client.getProject(parseInt(projectId, 10));
setProjectInfo(project);
-
+
// Fetch environments
- const envs = await client.getProjectEnvironments(parseInt(projectId));
+ const envs = await client.getProjectEnvironments(parseInt(projectId, 10));
setEnvironments(envs);
-
- // Select first environment by default and fetch its features
+
+ // Select first environment by default
if (envs.length > 0) {
setSelectedEnvironment(envs[0].id);
- // We'll fetch features for this environment in the effect below
}
-
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
@@ -331,44 +483,31 @@ export const FlagsTab = () => {
};
fetchData();
- }, [projectId, discoveryApi, fetchApi]);
+ }, [projectId, client]);
- // Separate effect to fetch features when environment changes
+ // Fetch features when environment changes
useEffect(() => {
if (!selectedEnvironment || !projectId) return;
const fetchFeaturesForEnvironment = async () => {
setFeaturesLoading(true);
try {
- const client = new FlagsmithClient(discoveryApi, fetchApi);
-
- // Fetch features for the selected environment
- // We'll need to add this method to the client
- const envFeatures = await client.getEnvironmentFeatures(selectedEnvironment, projectId);
- setFeatures(envFeatures);
-
+ // Just get project features - details loaded lazily on expand
+ const projectFeatures = await client.getProjectFeatures(projectId);
+ setFeatures(projectFeatures);
} catch (err) {
- //console.error('Failed to fetch environment features:', err);
- // For now, fall back to project features
- try {
- const client = new FlagsmithClient(discoveryApi, fetchApi);
- const projectFeatures = await client.getProjectFeatures(projectId);
- setFeatures(projectFeatures);
- } catch (fallbackErr) {
- setError('Failed to fetch features');
- }
+ setError('Failed to fetch features');
} finally {
setFeaturesLoading(false);
}
};
fetchFeaturesForEnvironment();
- }, [selectedEnvironment, projectId, discoveryApi, fetchApi]);
+ }, [selectedEnvironment, projectId, client]);
// Handle environment selection change
const handleEnvironmentChange = (envId: number) => {
setSelectedEnvironment(envId);
- // Features will be fetched by the useEffect above
};
if (loading) {
@@ -382,12 +521,11 @@ export const FlagsTab = () => {
if (error) {
return (
-
- Error: {error}
-
+ Error: {error}
{!projectId && (
- Add a flagsmith.com/project-id annotation to this entity to view feature flags.
+ Add a flagsmith.com/project-id annotation to this
+ entity to view feature flags.
)}
@@ -398,23 +536,21 @@ export const FlagsTab = () => {
-
- Feature Flags
-
+ Feature Flags
{projectInfo?.name} ({features.length} flags)
-
+
Environment