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