From cca4ebaae2fd64bb276ee46b58968ee44bef2771 Mon Sep 17 00:00:00 2001 From: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:17:42 +0800 Subject: [PATCH] Improve Konnect sync UX [INS-2697] (#10038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Close konnect configure modal after validating PAT. * Remove unused file * refactor: update delete/remove terminology for projects and workspaces based on konnect control plane presence * Prevent users from changing the sync type for konnect projects * Show Konnect tab when their are no projects under org. * tmp * Only create necessary konnect proxy env vars (#10005) * Apply icons for konnect projects * fix: remove Buffer class usage in renderer code (#10031) * Streamline workspace create & settings form [INS-2621] (#9940) * fix: skip file name collision validation when file name is unchanged The validate callback parameter shadowed the outer `fileName` variable (which holds the original name with extension). The folder-children filter compared against the bare input value instead of the full `fileName`, so the current file was never excluded — causing a false "already exists" error whenever only the workspace name was edited. Renaming the parameter to `inputValue` restores access to the outer `fileName` so the filter correctly excludes the existing file before checking for collisions. Co-Authored-By: Claude Sonnet 4.6 * fix: make .yaml extension shift with input text in workspace settings The invisible sizer span that drives the CSS grid column width had static content (the initial filename), so the column never resized as the user typed and the .yaml suffix stayed at a fixed position. Switching the TextField to controlled mode (value + onChange) lets the sizer span reflect the live input value, causing the .yaml label to follow the text as characters are added or removed. Also removed the excess pr-7 right-padding since the extension is now positioned by the grid rather than by padding offset. Co-Authored-By: Claude Sonnet 4.6 * fix: allow workspace filename input to adapt down to zero width inputs * fix: sanitize file name value in workspace settings modal Apply safeToUseInsomniaFileName to the TextField value prop so the displayed and submitted value is always sanitized, matching the pattern used in new-workspace-modal. Previously the controlled value reflected raw input directly, bypassing character replacement. Co-Authored-By: Claude Sonnet 4.6 * fix: minor right padding correction for consistency between new/edit workspace settings filename input * fix: remove unnecessary w-min from new workspace modal as well --------- Co-authored-by: Claude Sonnet 4.6 * feat: enhance konnect sync UX with tooltip for last synced time * feat: enhance konnect sync UX by navigating to the first project after sync * feat: add onboarding modal for Konnect environment setup after first sync * feat: refactor getKonnectDeploymentType for improved control plane type handling * Fix flaky Konnect smoke test sync assertion * Update packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding.tsx Co-authored-by: Missy Turco <60163079+mcturco@users.noreply.github.com> * Update packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding.tsx Co-authored-by: Missy Turco <60163079+mcturco@users.noreply.github.com> * refactor: remove click and escape handlers from KonnectEnvOnboarding component * fix: remove unnecessary filter for proxy defaults in upsertProjectEnvVars function * Keep in Konnect tab after deleting konnect projects. * Fix Konnect proxy env var creation on sync * Add Kubernetes Ingress Controller SVG icon to project navigation sidebar * feat: add k8sIngressController deployment type and corresponding icon - Updated getKonnectDeploymentType to return 'k8sIngressController' for K8SIngressController control plane type. - Added k8sIngressControllerIcon to the konnectDeploymentTypeToIcon mapping. - Fixed the path for serverless.svg and added a new serverless.svg file with the appropriate SVG content. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor: replace database queries with services for project listing and deletion * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor: update control plane configuration to enforce cloud_gateway property and improve deployment type handling * fix: memoize createInProjectActionList to prevent DOM detachment in menu * fix: remove proxy defaults check in upsertProjectEnvVars function * fix: update sync logic to handle environment onboarding and navigation for first successful sync * fix: add LastSyncedLabel component for improved sync status display * fix: simplify active tab update logic in project navigation sidebar * fix: update environment variable mapping tests for proxy vars handling --------- Co-authored-by: Ryan Willis Co-authored-by: Vivek Thuravupala <2700229+godfrzero@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Missy Turco <60163079+mcturco@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> (cherry picked from commit 5c08a0383c9d7988f57b8acf17d02d8d06b47eaf) --- packages/insomnia-data/src/models/project.ts | 5 +- packages/insomnia-data/src/models/types.ts | 2 +- .../tests/smoke/konnect.test.ts | 3 +- .../src/konnect/__tests__/api.test.ts | 163 +- .../src/konnect/__tests__/sync.test.ts | 1499 +++++++++++------ packages/insomnia/src/konnect/api.ts | 24 +- packages/insomnia/src/konnect/sync.ts | 16 +- packages/insomnia/src/konnect/transform.ts | 155 +- ...ganizationId.project.$projectId._index.tsx | 143 +- ...ganizationId.project.$projectId.delete.tsx | 17 + ...ion.$organizationId.project.$projectId.tsx | 22 + .../dropdowns/sidebar-project-dropdown.tsx | 12 +- .../dropdowns/sidebar-workspace-dropdown.tsx | 23 +- .../dropdowns/workspace-card-dropdown.tsx | 23 +- .../dropdowns/workspace-dropdown.tsx | 502 ------ .../modals/konnect-settings-modal.tsx | 4 + .../components/modals/sync-delete-modal.tsx | 86 - .../project/project-settings-form.tsx | 24 +- .../konnect-env-onboarding.tsx | 61 + .../konnect-project-icon/dedicated-cloud.svg | 14 + .../konnect-project-icon/group.svg | 7 + .../k8s-ingress-controller.svg | 13 + .../konnect-project-icon.tsx | 28 + .../konnect-project-icon/self-managed.svg | 9 + .../konnect-project-icon/serverless.svg | 9 + .../project-navigation-sidebar.tsx | 116 +- .../project-node.tsx | 23 +- .../workspace-node.tsx | 16 +- 28 files changed, 1768 insertions(+), 1251 deletions(-) delete mode 100644 packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx delete mode 100644 packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding.tsx create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg diff --git a/packages/insomnia-data/src/models/project.ts b/packages/insomnia-data/src/models/project.ts index fd85025446..03c052fd55 100644 --- a/packages/insomnia-data/src/models/project.ts +++ b/packages/insomnia-data/src/models/project.ts @@ -86,11 +86,14 @@ export const isGitProject = (project: Project): project is GitProject => 'gitRepositoryId' in project && (project.gitRepositoryId !== null || isEmptyGitProject(project)); export const projectHasSettings = (project: Pick) => !isScratchpadProject(project); +export type KonnectDeploymentType = 'selfManaged' | 'serverless' | 'dedicatedCloud' | 'group' | 'k8sIngressController'; + interface CommonProject { name: string; mcpStdioAccess?: boolean; konnectControlPlaneId?: string | null; konnectClusterType?: string | null; + konnectDeploymentType?: KonnectDeploymentType | null; } export interface RemoteProject extends BaseModel, CommonProject { @@ -114,7 +117,7 @@ export const isProject = (model: Pick): model is Project => m export const isProjectId = (id: string | null) => id?.startsWith(`${prefix}_`); -export const optionalKeys = ['konnectControlPlaneId', 'konnectClusterType']; +export const optionalKeys = ['konnectControlPlaneId', 'konnectClusterType', 'konnectDeploymentType']; export function init(): Partial { return { diff --git a/packages/insomnia-data/src/models/types.ts b/packages/insomnia-data/src/models/types.ts index 007ea3b59b..99be07cde6 100644 --- a/packages/insomnia-data/src/models/types.ts +++ b/packages/insomnia-data/src/models/types.ts @@ -79,7 +79,7 @@ export type { ResponseInfo, RunnerResultPerRequestPerIteration, } from './runner-test-result'; -export type { Project, LocalProject, RemoteProject, GitProject } from './project'; +export type { Project, LocalProject, RemoteProject, GitProject, KonnectDeploymentType } from './project'; export type { ProjectLintRuleset } from './project-lint-ruleset'; export type { Settings, ThemeSettings } from './settings'; export type { Stats } from './stats'; diff --git a/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts b/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts index 8d71df3e44..ae8a185790 100644 --- a/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts @@ -10,8 +10,7 @@ test.describe('Konnect sidebar tab', () => { await page.getByRole('button', { name: 'Configure' }).click(); await page.getByLabel('Personal Access Token').fill('kpat_test'); await page.getByRole('button', { name: 'Connect & Sync' }).click(); - await expect.soft(page.getByText('Connected')).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).click(); + await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden(); await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible(); diff --git a/packages/insomnia/src/konnect/__tests__/api.test.ts b/packages/insomnia/src/konnect/__tests__/api.test.ts index b587977aff..8408d522dc 100644 --- a/packages/insomnia/src/konnect/__tests__/api.test.ts +++ b/packages/insomnia/src/konnect/__tests__/api.test.ts @@ -116,10 +116,18 @@ describe('validatePat', () => { describe('fetchAllControlPlanes', () => { it('yields a single page when total <= PAGE_SIZE', async () => { - const page1Data = [{ id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } }), - )); + const page1Data = [ + { + id: 'cp-1', + name: 'CP 1', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }, + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } })), + ); const pages: any[][] = []; for await (const page of fetchAllControlPlanes('faketoken')) { @@ -132,11 +140,22 @@ describe('fetchAllControlPlanes', () => { it('yields multiple pages when total > PAGE_SIZE', async () => { const page1Data = Array.from({ length: 100 }, (_, i) => ({ - id: `cp-${i}`, name: `CP ${i}`, description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' }, + id: `cp-${i}`, + name: `CP ${i}`, + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, })); - const page2Data = [{ id: 'cp-100', name: 'CP 100', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; + const page2Data = [ + { + id: 'cp-100', + name: 'CP 100', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse({ data: page1Data, meta: { page: { total: 101, size: 100, number: 1 } } })) .mockResolvedValueOnce(jsonResponse({ data: page2Data, meta: { page: { total: 101, size: 100, number: 2 } } })); vi.stubGlobal('fetch', fetchMock); @@ -166,10 +185,16 @@ describe('fetchAllControlPlanes', () => { // field must be defined, even when the upstream payload omits it. Without // this, downstream code reading `controlPlane.proxy_urls` would see // `undefined` despite the type saying otherwise. - const rawCp = { id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: [rawCp], meta: { page: { total: 1, size: 100, number: 1 } } }), - )); + const rawCp = { + id: 'cp-1', + name: 'CP 1', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: [rawCp], meta: { page: { total: 1, size: 100, number: 1 } } })), + ); const pages: any[][] = []; for await (const page of fetchAllControlPlanes('faketoken')) { @@ -180,9 +205,10 @@ describe('fetchAllControlPlanes', () => { }); it('yields empty data when total is 0', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: [], meta: { page: { total: 0, size: 100, number: 1 } } }), - )); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: [], meta: { page: { total: 0, size: 100, number: 1 } } })), + ); const pages: any[][] = []; for await (const page of fetchAllControlPlanes('faketoken')) { @@ -198,7 +224,9 @@ describe('fetchAllControlPlanes', () => { describe('fetchAllServices', () => { it('fetches a single page when offset is null', async () => { - const services = [{ id: 'svc-1', name: 'Svc', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; + const services = [ + { id: 'svc-1', name: 'Svc', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: services, offset: null }))); const result = await fetchAllServices('faketoken', 'cp-1', 'us'); @@ -207,10 +235,15 @@ describe('fetchAllServices', () => { }); it('follows offset pagination across multiple pages', async () => { - const page1 = [{ id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const page2 = [{ id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; + const page1 = [ + { id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const page2 = [ + { id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse({ data: page1, offset: 'cursor-abc' })) .mockResolvedValueOnce(jsonResponse({ data: page2, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -260,10 +293,37 @@ describe('fetchAllServices', () => { describe('fetchRoutesForService', () => { it('follows offset pagination across multiple pages', async () => { - const page1 = [{ id: 'r-1', name: null, methods: ['GET'], paths: ['/a'], protocols: ['http'], hosts: null, headers: null, snis: null, expression: null, service: { id: 'svc-1' } }]; - const page2 = [{ id: 'r-2', name: null, methods: ['POST'], paths: ['/b'], protocols: ['http'], hosts: null, headers: null, snis: null, expression: null, service: { id: 'svc-1' } }]; + const page1 = [ + { + id: 'r-1', + name: null, + methods: ['GET'], + paths: ['/a'], + protocols: ['http'], + hosts: null, + headers: null, + snis: null, + expression: null, + service: { id: 'svc-1' }, + }, + ]; + const page2 = [ + { + id: 'r-2', + name: null, + methods: ['POST'], + paths: ['/b'], + protocols: ['http'], + hosts: null, + headers: null, + snis: null, + expression: null, + service: { id: 'svc-1' }, + }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse({ data: page1, offset: 'cursor-xyz' })) .mockResolvedValueOnce(jsonResponse({ data: page2, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -313,8 +373,11 @@ describe('fetchRoutesForService', () => { describe('retry on 429', () => { it('retries and succeeds after a single 429', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse()) .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -329,8 +392,11 @@ describe('retry on 429', () => { }); it('uses Retry-After header value in seconds when present', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse('3')) .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -350,8 +416,11 @@ describe('retry on 429', () => { }); it('uses exponential backoff when Retry-After is missing', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse()) .mockResolvedValueOnce(rateLimitResponse()) .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); @@ -395,13 +464,16 @@ describe('retry on 429', () => { }); it('retries through all 5 attempts before succeeding', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() - .mockResolvedValueOnce(rateLimitResponse()) // attempt 0 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 1 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 2 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 3 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 4 + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(rateLimitResponse()) // attempt 0 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 1 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 2 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 3 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 4 .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -415,10 +487,15 @@ describe('retry on 429', () => { }); it('retries work across paginated requests', async () => { - const page1 = [{ id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const page2 = [{ id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; + const page1 = [ + { id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const page2 = [ + { id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() // First page succeeds immediately .mockResolvedValueOnce(jsonResponse({ data: page1, offset: 'next' })) // Second page hits a 429 then succeeds @@ -436,9 +513,17 @@ describe('retry on 429', () => { }); it('retries work for fetchAllControlPlanes pagination', async () => { - const page1Data = [{ id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; + const page1Data = [ + { + id: 'cp-1', + name: 'CP 1', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse('1')) .mockResolvedValueOnce(jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } })); vi.stubGlobal('fetch', fetchMock); diff --git a/packages/insomnia/src/konnect/__tests__/sync.test.ts b/packages/insomnia/src/konnect/__tests__/sync.test.ts index f841bcbcd7..9a10c3cbc8 100644 --- a/packages/insomnia/src/konnect/__tests__/sync.test.ts +++ b/packages/insomnia/src/konnect/__tests__/sync.test.ts @@ -28,6 +28,7 @@ function makeCp(overrides: Partial = {}): KonnectControlPla config: { cluster_type: 'CLUSTER_TYPE_HYBRID', control_plane_endpoint: 'https://abc123.us.cp0.konghq.com', + cloud_gateway: true, }, proxy_urls: null, ...overrides, @@ -71,11 +72,7 @@ function makeRoute(overrides: Partial = {}): KonnectRoute { * - Control planes: page-number pagination (meta.page.total) * - Services / routes: cursor pagination (offset field) */ -function mockFetch( - cps: KonnectControlPlane[], - services: KonnectService[], - routes: KonnectRoute[], -) { +function mockFetch(cps: KonnectControlPlane[], services: KonnectService[], routes: KonnectRoute[]) { const json = (data: unknown) => new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -120,11 +117,21 @@ afterEach(() => { describe('Feature: HTTP Route Sync', () => { it('Scenario: Explicit methods, single path — both protocols', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET', 'POST'], paths: ['/explicit-methods'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET', 'POST'], + paths: ['/explicit-methods'], + protocols: ['http', 'https'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -137,10 +144,30 @@ describe('Feature: HTTP Route Sync', () => { const httpPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':http')); const httpsPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':https')); - expect(httpGet).toMatchObject({ method: 'GET', url: 'http://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:http' }); - expect(httpsGet).toMatchObject({ method: 'GET', url: 'https://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:https' }); - expect(httpPost).toMatchObject({ method: 'POST', url: 'http://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:http' }); - expect(httpsPost).toMatchObject({ method: 'POST', url: 'https://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:https' }); + expect(httpGet).toMatchObject({ + method: 'GET', + url: 'http://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:http', + }); + expect(httpsGet).toMatchObject({ + method: 'GET', + url: 'https://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:https', + }); + expect(httpPost).toMatchObject({ + method: 'POST', + url: 'http://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:http', + }); + expect(httpsPost).toMatchObject({ + method: 'POST', + url: 'https://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:https', + }); // Should be in sub-folders (multi-protocol → needsSubFolders) const folders = await db.find(models.requestGroup.type, { konnectRouteId: 'route-uuid-1' }); @@ -148,11 +175,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Single method, single path — http only', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['DELETE'], paths: ['/single-method'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['DELETE'], paths: ['/single-method'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -168,11 +198,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Single method, single path — https only', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/https-only'], protocols: ['https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/https-only'], protocols: ['https'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -186,11 +219,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: methods null — defaults to GET/POST/PUT/DELETE/PATCH per protocol', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/methods-null'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/methods-null'], protocols: ['http', 'https'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -209,11 +245,21 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Multiple paths — route folder with path x protocol sub-folders', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-mp', methods: ['GET', 'POST'], paths: ['/multi-path-v1', '/multi-path-v2'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-mp', + methods: ['GET', 'POST'], + paths: ['/multi-path-v1', '/multi-path-v2'], + protocols: ['http', 'https'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -225,11 +271,22 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: paths null, host-only matching — URL has no path suffix, host set as header', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-1', methods: ['GET'], paths: null, hosts: ['host-only.example.com'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-1', + methods: ['GET'], + paths: null, + hosts: ['host-only.example.com'], + protocols: ['http', 'https'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -241,16 +298,28 @@ describe('Feature: HTTP Route Sync', () => { expect(httpReq).toMatchObject({ url: 'http://{{ _.proxy_host }}', name: 'Route route-1' }); expect(httpsReq).toMatchObject({ url: 'https://{{ _.proxy_host }}', name: 'Route route-1' }); for (const req of requests) { - expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'host-only.example.com' }])); + expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'host-only.example.com' }])); } }); it('Scenario: paths null, header-only matching — URL has no path suffix, matching headers set', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-1', methods: ['GET'], paths: null, hosts: null, headers: { 'X-Service': ['header-only'] }, protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-1', + methods: ['GET'], + paths: null, + hosts: null, + headers: { 'X-Service': ['header-only'] }, + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -261,16 +330,21 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Route headers synced onto the request — first value only', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ - methods: ['POST'], - paths: ['/route-headers'], - headers: { 'X-Api-Version': ['2', '3'], 'X-Region': ['us-east'] }, - protocols: ['http'], - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + methods: ['POST'], + paths: ['/route-headers'], + headers: { 'X-Api-Version': ['2', '3'], 'X-Region': ['us-east'] }, + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -284,26 +358,37 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Route hosts synced as Host header', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['POST'], paths: ['/route-hosts'], hosts: ['route-hosts.example.com'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + methods: ['POST'], + paths: ['/route-hosts'], + hosts: ['route-hosts.example.com'], + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); - expect(requests[0].headers).toEqual( - expect.arrayContaining([{ name: 'host', value:'route-hosts.example.com' }]), - ); + expect(requests[0].headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'route-hosts.example.com' }])); }); it('Scenario: Regex path with shorthand class — falls back to /:path with path parameter', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['GET'], paths: ['~/regex/\\d+'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['~/regex/\\d+'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -316,11 +401,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Regex path with named capture group — parsed to colon param in URL and pathParameters', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -333,11 +421,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: strip_path and preserve_host — ignored (no effect on request URL)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['GET'], paths: ['/strip-path'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['/strip-path'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -346,11 +437,21 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: SNIs on a route — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ protocols: ['https'], methods: ['GET'], paths: ['/sni-route'], snis: ['secure-users.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['https'], + methods: ['GET'], + paths: ['/sni-route'], + snis: ['secure-users.example.com'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -364,10 +465,14 @@ describe('Feature: HTTP Route Sync', () => { describe('Feature: Request Naming', () => { it('Scenario: Path exists — name is path (route name ignored)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: ['GET'], paths: ['/naming-path-wins'], name: 'list-users', protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['/naming-path-wins'], name: 'list-users', protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -376,31 +481,49 @@ describe('Feature: Request Naming', () => { }); it('Scenario: No path, route name exists — name is route name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: ['GET'], paths: null, hosts: ['naming-route-name.example.com'], name: 'users-root', protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + methods: ['GET'], + paths: null, + hosts: ['naming-route-name.example.com'], + name: 'users-root', + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [req] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(req.name).toBe('users-root'); - expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'naming-route-name.example.com' }])); + expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'naming-route-name.example.com' }])); }); it('Scenario: No path, no name — name falls back to "Route {routeId}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - methods: ['GET'], - paths: null, - hosts: null, - name: null, - headers: { 'X-Service': ['naming-no-name'] }, - protocols: ['http'], - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + methods: ['GET'], + paths: null, + hosts: null, + name: null, + headers: { 'X-Service': ['naming-no-name'] }, + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -410,10 +533,14 @@ describe('Feature: Request Naming', () => { }); it('Scenario: methods null — all default methods use path in name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: null, paths: ['/naming-methods-null'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: null, paths: ['/naming-methods-null'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -430,10 +557,14 @@ describe('Feature: Request Naming', () => { describe('Feature: Re-sync', () => { it('Scenario: Re-sync preserves user customizations on matched requests', async () => { // First sync - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User adds a custom header and body @@ -444,10 +575,14 @@ describe('Feature: Re-sync', () => { }); // Second sync — same route, same path (no change) - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.updated).toBe(0); @@ -456,27 +591,33 @@ describe('Feature: Re-sync', () => { const [updated] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(updated.url).toBe('http://{{ _.proxy_host }}/v1/users'); // User's custom header should still be present - expect(updated.headers).toEqual( - expect.arrayContaining([{ name: 'X-Custom', value: 'my-token' }]), - ); + expect(updated.headers).toEqual(expect.arrayContaining([{ name: 'X-Custom', value: 'my-token' }])); // User's body should still be there expect(updated.body?.text).toBe('{"foo":"bar"}'); }); it('Scenario: Re-sync creates new request when route path changes; old request deleted', async () => { // First sync - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(1); // Second sync — path changes to /v2/users (new key, old key stale) - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.created).toBe(1); @@ -503,10 +644,14 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync deletes request when route is removed from Konnect', async () => { // First sync — create the request - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(1); @@ -520,21 +665,34 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync deletes user-added requests', async () => { // First sync - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // Find the workspace and add a manual request const workspaces = konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } })); - await insoservices.request.create({ parentId: workspaces[0]._id, name: 'Manual Request', url: 'http://example.com', method: 'GET' }); + await insoservices.request.create({ + parentId: workspaces[0]._id, + name: 'Manual Request', + url: 'http://example.com', + method: 'GET', + }); // Re-sync — the user-added request should be deleted - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.deleted).toBe(1); // the manual request @@ -545,21 +703,37 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync removes Konnect-managed Host header when hosts is cleared', async () => { // First sync — route has a hosts entry, produces a Host header - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: ['api.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['/api'], + protocols: ['http'], + hosts: ['api.example.com'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after1] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); - expect(after1.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'api.example.com' }])); + expect(after1.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'api.example.com' }])); expect(after1.konnectManagedHeaderNames).toContain('host'); // Second sync — hosts cleared; Host header should be removed - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after2] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -568,20 +742,36 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync removes a Konnect-managed route header when it is dropped from the route', async () => { // First sync — route has X-Tenant header - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], headers: { 'X-Tenant': ['acme'] } })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['/api'], + protocols: ['http'], + headers: { 'X-Tenant': ['acme'] }, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after1] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(after1.headers).toEqual(expect.arrayContaining([{ name: 'x-tenant', value: 'acme' }])); // Second sync — X-Tenant removed from route - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], headers: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], headers: null })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after2] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -590,10 +780,22 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync preserves user-added headers when Konnect-managed headers are removed', async () => { // First sync — route has Host header - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: ['api.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['/api'], + protocols: ['http'], + hosts: ['api.example.com'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User adds their own header @@ -603,10 +805,14 @@ describe('Feature: Re-sync', () => { }); // Second sync — hosts cleared; Host removed, user header preserved - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after2] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -616,10 +822,14 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync removes empty sub-folders when route path changes', async () => { // First sync — multi-path route creates sub-folders - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users', '/v2/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users', '/v2/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const folders1 = await db.find(models.requestGroup.type, { konnectRouteId: 'route-uuid-1' }); @@ -627,10 +837,14 @@ describe('Feature: Re-sync', () => { expect(folders1.length).toBeGreaterThanOrEqual(2); // Second sync — path list changes; /v1/users gone, /v3/users added - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users', '/v3/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users', '/v3/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const folders2 = await db.find(models.requestGroup.type, { konnectRouteId: 'route-uuid-1' }); @@ -640,10 +854,14 @@ describe('Feature: Re-sync', () => { }); it('Scenario: Re-sync resets method if the user changed it', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User changes method to POST @@ -651,10 +869,14 @@ describe('Feature: Re-sync', () => { await insoservices.request.update(created, { method: 'POST' }); // Re-sync should reset method back to GET - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [updated] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -662,10 +884,21 @@ describe('Feature: Re-sync', () => { }); it('Scenario: Re-sync preserves user-filled path param value when regex is unchanged', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User fills in the path param value @@ -673,10 +906,21 @@ describe('Feature: Re-sync', () => { await insoservices.request.update(created, { pathParameters: [{ name: 'userid', value: '42' }] }); // Re-sync — same regex, no change - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.updated).toBe(0); @@ -685,10 +929,21 @@ describe('Feature: Re-sync', () => { }); it('Scenario: Re-sync when regex capture group is renamed — old value dropped, new empty param created', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User fills in the path param value @@ -698,10 +953,21 @@ describe('Feature: Re-sync', () => { // Re-sync — capture group renamed from userId to accountId. // The raw regex path is part of the route key, so a different capture group name // produces a different key -> the old request is deleted and a new one is created. - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.created).toBe(1); @@ -717,10 +983,14 @@ describe('Feature: Re-sync', () => { describe('Feature: Idempotent Sync (Route Keying)', () => { it('Scenario: Route keys for multi-method HTTP route — keyed as "routeId:method:path:protocol"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET', 'POST'], paths: ['/api/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET', 'POST'], paths: ['/api/v1/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -731,10 +1001,14 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { }); it('Scenario: Route key for methods null — keyed per default method', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -751,10 +1025,14 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { }); it('Scenario: Route key for gRPC route — keyed as "routeId:grpc:path:protocol"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpc'], methods: null, paths: ['/mypackage.MyService'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-3', protocols: ['grpc'], methods: null, paths: ['/mypackage.MyService'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -764,10 +1042,14 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { }); it('Scenario: Route key for WebSocket route — keyed as "routeId:ws:path:protocol"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/chat'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/chat'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -781,10 +1063,21 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { describe('Feature: gRPC Route Sync', () => { it('Scenario: grpc protocol, path present — path becomes protoMethodName and name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpc'], methods: null, paths: ['/hello.HelloService/SayHello'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-3', + protocols: ['grpc'], + methods: null, + paths: ['/hello.HelloService/SayHello'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -800,10 +1093,14 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: grpcs protocol — creates a single gRPC request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpcs'], methods: null, paths: ['/grpcbin.GRPCBin/Empty'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-3', protocols: ['grpcs'], methods: null, paths: ['/grpcbin.GRPCBin/Empty'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -816,10 +1113,14 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: grpc + grpcs mixed — creates two gRPC requests', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpc', 'grpcs'], methods: null, paths: ['/addsvc.Add/Sum'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-3', protocols: ['grpc', 'grpcs'], methods: null, paths: ['/addsvc.Add/Sum'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -835,10 +1136,22 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: paths null, route name present — name falls back to route name, protoMethodName empty', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: null, hosts: ['grpc-name.example.com'], name: 'my-grpc-service' })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: null, + hosts: ['grpc-name.example.com'], + name: 'my-grpc-service', + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -849,10 +1162,23 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: paths null, no route name — name falls back to "Route {routeId}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', protocols: ['grpc'], methods: null, paths: null, hosts: ['grpc-no-name.example.com'], name: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + protocols: ['grpc'], + methods: null, + paths: null, + hosts: ['grpc-no-name.example.com'], + name: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -862,10 +1188,20 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: Multiple paths — one request per path', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: ['/hello.HelloService/LotsOfGreetings', '/hello.HelloService/LotsOfReplies'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: ['/hello.HelloService/LotsOfGreetings', '/hello.HelloService/LotsOfReplies'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -876,10 +1212,21 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: hosts present — host not set as metadata', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: ['/grpcbin.GRPCBin/DummyUnary'], hosts: ['grpc-hosts.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: ['/grpcbin.GRPCBin/DummyUnary'], + hosts: ['grpc-hosts.example.com'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -888,10 +1235,21 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: grpcs with snis — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpcs'], methods: null, paths: ['/grpcbin.GRPCBin/Index'], snis: ['grpc.secure.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpcs'], + methods: null, + paths: ['/grpcbin.GRPCBin/Index'], + snis: ['grpc.secure.example.com'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -901,18 +1259,31 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: headers present — synced as gRPC metadata (first value only)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: ['/grpcbin.GRPCBin/HeadersUnary'], headers: { 'X-Api-Version': ['2'], 'X-Tenant': ['acme'] } })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: ['/grpcbin.GRPCBin/HeadersUnary'], + headers: { 'X-Api-Version': ['2'], 'X-Tenant': ['acme'] }, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [grpcReq] = konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } })); - expect(grpcReq.metadata).toEqual(expect.arrayContaining([ - { name: 'x-api-version', value: '2' }, - { name: 'x-tenant', value: 'acme' }, - ])); + expect(grpcReq.metadata).toEqual( + expect.arrayContaining([ + { name: 'x-api-version', value: '2' }, + { name: 'x-tenant', value: 'acme' }, + ]), + ); }); }); @@ -920,10 +1291,14 @@ describe('Feature: gRPC Route Sync', () => { describe('Feature: WebSocket Route Sync', () => { it('Scenario: ws protocol, path present', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/plain'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/plain'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -937,10 +1312,14 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: wss protocol — creates a single WebSocket request with wss', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['wss'], methods: null, paths: ['/ws/secure'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['wss'], methods: null, paths: ['/ws/secure'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -953,10 +1332,14 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: ws + wss mixed — creates two WebSocket requests', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['ws', 'wss'], methods: null, paths: ['/ws/mixed'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['ws', 'wss'], methods: null, paths: ['/ws/mixed'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -972,37 +1355,66 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: paths null, route name present — name falls back to route name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['wss'], methods: null, paths: null, hosts: ['ws-name.example.com'], name: 'ws-chat-service' })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['wss'], + methods: null, + paths: null, + hosts: ['ws-name.example.com'], + name: 'ws-chat-service', + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [wsReq] = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); expect(wsReq.name).toBe('ws-chat-service'); expect(wsReq.url).toBe('wss://{{ _.proxy_host }}'); - expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'ws-name.example.com' }])); + expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'ws-name.example.com' }])); }); it('Scenario: paths null, no route name — name falls back to "Route {routeId}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'ws-uuid-no-name', protocols: ['wss'], methods: null, paths: null, hosts: ['ws-no-name.example.com'], name: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'ws-uuid-no-name', + protocols: ['wss'], + methods: null, + paths: null, + hosts: ['ws-no-name.example.com'], + name: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [wsReq] = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); expect(wsReq.name).toBe('Route ws-uuid-no-name'); - expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'ws-no-name.example.com' }])); + expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'ws-no-name.example.com' }])); }); it('Scenario: Multiple paths — one request per path', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/multi-v1', '/ws/multi-v2'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/multi-v1', '/ws/multi-v2'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1013,10 +1425,14 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: headers present — synced onto the request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/headers'], headers: { 'X-Tenant': ['acme'] } })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/headers'], headers: { 'X-Tenant': ['acme'] } })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1025,26 +1441,36 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: hosts present — synced as Host header', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/hosts'], hosts: ['ws-hosts.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/hosts'], hosts: ['ws-hosts.example.com'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [wsReq] = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); - expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'ws-hosts.example.com' }])); + expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'ws-hosts.example.com' }])); }); it('Scenario: wss with snis — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['wss'], methods: null, paths: ['/ws/sni'], snis: ['ws.secure.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['wss'], methods: null, paths: ['/ws/sni'], snis: ['ws.secure.example.com'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); - expect(konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); + expect( + konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })), + ).toHaveLength(0); expect(result.routes.skipped).toBe(1); }); }); @@ -1058,15 +1484,17 @@ describe('Feature: L4 Stream Routes — Skipped', () => { ['udp', ['udp']], ['tls_passthrough', ['tls_passthrough']], ])('Scenario: %s route creates no request and increments skipped count', async (_label, protocols) => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: protocols as string[], methods: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch([makeCp()], [makeService()], [makeRoute({ protocols: protocols as string[], methods: null })]), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); - expect(konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); + expect( + konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })), + ).toHaveLength(0); expect(konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); expect(result.routes.skipped).toBe(1); expect(result.routes.total).toBe(0); @@ -1077,17 +1505,23 @@ describe('Feature: L4 Stream Routes — Skipped', () => { describe('Feature: SNI-Only Routes', () => { it('Scenario: HTTPS route matched only by SNI — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['https'], - snis: ['api.secure.example.com'], - paths: null, - methods: null, - hosts: null, - headers: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['https'], + snis: ['api.secure.example.com'], + paths: null, + methods: null, + hosts: null, + headers: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1100,11 +1534,7 @@ describe('Feature: SNI-Only Routes', () => { describe('Feature: Collection Naming', () => { it('Scenario: Service with a name — collection named after service', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService({ id: 'svc-uuid-1', name: 'User Service' })], - [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp()], [makeService({ id: 'svc-uuid-1', name: 'User Service' })], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1114,11 +1544,7 @@ describe('Feature: Collection Naming', () => { }); it('Scenario: Service with no name — collection named "Gateway Service {id}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService({ id: 'svc-uuid-2', name: null })], - [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp()], [makeService({ id: 'svc-uuid-2', name: null })], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1141,14 +1567,18 @@ describe('Feature: Collection Naming', () => { it('Scenario: Re-sync deletes collection when service is removed from Konnect', async () => { vi.stubGlobal('fetch', mockFetch([makeCp()], [makeService({ id: 'svc-uuid-1' })], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); - expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength(1); + expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength( + 1, + ); // Service gone from Konnect vi.stubGlobal('fetch', mockFetch([makeCp()], [], [])); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.services.deleted).toBe(1); - expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength(0); + expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength( + 0, + ); }); }); @@ -1156,10 +1586,7 @@ describe('Feature: Collection Naming', () => { describe('Feature: Environment Variable Mapping', () => { it('Scenario: Sync writes empty proxy placeholder vars for manual entry', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [], [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp()], [], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1169,11 +1596,6 @@ describe('Feature: Environment Variable Mapping', () => { expect(kvNames).toContain('proxy_host'); expect(kvNames).toContain('grpc_proxy_host'); expect(kvNames).toContain('grpcs_proxy_host'); - // All values should be empty strings - for (const name of ['proxy_host', 'grpc_proxy_host', 'grpcs_proxy_host']) { - const kv = (env.kvPairData ?? []).find((kv: any) => kv.name === name); - expect(kv?.value).toBe(''); - } }); it('Scenario: Re-sync preserves user-entered proxy values and user-added variables', async () => { @@ -1201,16 +1623,22 @@ describe('Feature: Environment Variable Mapping', () => { }); it('Scenario: Sync auto-fills proxy vars from control plane proxy_urls', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp({ - proxy_urls: [ - { host: 'proxy.example.com', port: 8443, protocol: 'https' }, - { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, - { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + vi.stubGlobal( + 'fetch', + mockFetch( + [ + makeCp({ + proxy_urls: [ + { host: 'proxy.example.com', port: 8443, protocol: 'https' }, + { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, + { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + ], + }), ], - })], - [], [], - )); + [], + [], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1238,14 +1666,18 @@ describe('Feature: Environment Variable Mapping', () => { await insoservices.environment.update(env, { kvPairData: updatedKvPairs }); // Re-sync with proxy_urls that would provide a different value - vi.stubGlobal('fetch', mockFetch( - [makeCp({ - proxy_urls: [ - { host: 'api-provided.example.com', port: 80, protocol: 'http' }, + vi.stubGlobal( + 'fetch', + mockFetch( + [ + makeCp({ + proxy_urls: [{ host: 'api-provided.example.com', port: 80, protocol: 'http' }], + }), ], - })], - [], [], - )); + [], + [], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const updated = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); @@ -1264,14 +1696,18 @@ describe('Feature: Environment Variable Mapping', () => { expect(proxyHost?.value).toBe(''); // Re-sync with proxy_urls now available - vi.stubGlobal('fetch', mockFetch( - [makeCp({ - proxy_urls: [ - { host: 'newly-available.example.com', port: 443, protocol: 'https' }, + vi.stubGlobal( + 'fetch', + mockFetch( + [ + makeCp({ + proxy_urls: [{ host: 'newly-available.example.com', port: 443, protocol: 'https' }], + }), ], - })], - [], [], - )); + [], + [], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const updated = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); @@ -1284,10 +1720,7 @@ describe('Feature: Environment Variable Mapping', () => { describe('Feature: Control Plane Naming', () => { it('Scenario: New control plane creates a project', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp({ id: 'cp-uuid-1', name: 'Production' })], - [], [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp({ id: 'cp-uuid-1', name: 'Production' })], [], [])); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1323,13 +1756,17 @@ describe('Feature: Control Plane Naming', () => { it('Scenario: Re-sync deletes project when CP is removed from Konnect', async () => { vi.stubGlobal('fetch', mockFetch([makeCp({ id: 'cp-uuid-1' })], [], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); - expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength(1); + expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength( + 1, + ); vi.stubGlobal('fetch', mockFetch([], [], [])); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.controlPlanes.deleted).toBe(1); - expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength(0); + expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength( + 0, + ); }); }); @@ -1337,32 +1774,42 @@ describe('Feature: Control Plane Naming', () => { describe('Feature: Wildcard and Edge-Case Hosts', () => { it('Scenario: Wildcard host — set as Host header, not in URL', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: ['GET'], paths: ['/api'], hosts: ['*.example.com'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['/api'], hosts: ['*.example.com'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [req] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(req.url).toBe('http://{{ _.proxy_host }}/api'); - expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'*.example.com' }])); + expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value: '*.example.com' }])); }); it('Scenario: Fully invalid route (no matching fields) — creates requests with "Route {uuid}" name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - id: 'a1b2c3d4-0000-0000-0000-000000000001', - protocols: ['http'], - methods: null, - paths: null, - hosts: null, - headers: null, - snis: null, - name: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'a1b2c3d4-0000-0000-0000-000000000001', + protocols: ['http'], + methods: null, + paths: null, + hosts: null, + headers: null, + snis: null, + name: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1379,16 +1826,22 @@ describe('Feature: Wildcard and Edge-Case Hosts', () => { describe('Feature: Expression-Based Routes', () => { it('Scenario: Simple method+path expression — creates 1 targeted request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.method == "GET" && http.path == "/foo"', - paths: null, - methods: null, - name: 'Foo Route', - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" && http.path == "/foo"', + paths: null, + methods: null, + name: 'Foo Route', + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1400,15 +1853,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Path-only expression — defaults to all 5 methods', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.path == "/api/users"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.path == "/api/users"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1420,15 +1879,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Multiple methods via OR expression', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.method == "GET" || http.method == "POST"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" || http.method == "POST"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1439,15 +1904,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Host expression — sets Host header on request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.host == "api.example.com" && http.method == "GET"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.host == "api.example.com" && http.method == "GET"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1457,15 +1928,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Header expression — sets extracted header on request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.headers.x_tenant == "acme" && http.method == "GET" && http.path == "/api"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.headers.x_tenant == "acme" && http.method == "GET" && http.path == "/api"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1475,15 +1952,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Unparseable expression — skipped (no requests created)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'net.src.ip in 10.0.0.0/8', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'net.src.ip in 10.0.0.0/8', + paths: null, + methods: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1492,15 +1975,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Partial expression (method extractable, rest unparseable) — creates request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1510,15 +1999,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Both protocols — creates requests for each', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http', 'https'], - expression: 'http.method == "GET" && http.path == "/foo"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http', 'https'], + expression: 'http.method == "GET" && http.path == "/foo"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1529,15 +2024,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Stream protocol — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['tcp'], - expression: 'net.dst.port == 5432', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['tcp'], + expression: 'net.dst.port == 5432', + paths: null, + methods: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1546,15 +2047,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Prefix path expression — creates requests at that path', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.path ^= "/api/v1"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.path ^= "/api/v1"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1566,19 +2073,25 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Repeated predicates in OR expansion — deduplicates methods/paths/hosts', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - // Each branch repeats the same method and path — a common pattern when - // parenthesised OR expansions duplicate shared predicates. - expression: - '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com") || ' + - '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com")', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + // Each branch repeats the same method and path — a common pattern when + // parenthesised OR expansions duplicate shared predicates. + expression: + '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com") || ' + + '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com")', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1589,15 +2102,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: tls.sni expression — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['https'], - expression: 'tls.sni == "secure.example.com" && http.method == "GET"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['https'], + expression: 'tls.sni == "secure.example.com" && http.method == "GET"', + paths: null, + methods: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); diff --git a/packages/insomnia/src/konnect/api.ts b/packages/insomnia/src/konnect/api.ts index b17a234541..9985ed8d0c 100644 --- a/packages/insomnia/src/konnect/api.ts +++ b/packages/insomnia/src/konnect/api.ts @@ -23,6 +23,7 @@ export interface KonnectControlPlane { config: { cluster_type: string; control_plane_endpoint: string; + cloud_gateway: boolean; }; proxy_urls: KonnectProxyUrl[] | null; } @@ -90,15 +91,23 @@ async function fetchWithRetry(url: string, pat: string, signal?: AbortSignal): P return response; } - const parsed = response.headers.get('Retry-After') ? Number.parseInt(response.headers.get('Retry-After')!, 10) : Number.NaN; - const delay = Number.isFinite(parsed) && parsed > 0 - ? parsed * 1000 - : Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); + const parsed = response.headers.get('Retry-After') + ? Number.parseInt(response.headers.get('Retry-After')!, 10) + : Number.NaN; + const delay = + Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); console.log(`[konnect] Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS})`); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, delay); - signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); }); attempt++; } @@ -129,10 +138,7 @@ export async function validatePat(pat: string): Promise { } } -export async function* fetchAllControlPlanes( - pat: string, - signal?: AbortSignal, -): AsyncGenerator { +export async function* fetchAllControlPlanes(pat: string, signal?: AbortSignal): AsyncGenerator { let page = 1; let totalPages = 1; diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index 757b618630..2b7b31deeb 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -2,6 +2,7 @@ import type { GrpcRequest, Project, Request, RequestGroup, WebSocketRequest, Wor import { EnvironmentKvPairDataType, models, services as insoservices } from 'insomnia-data'; import { database as db } from '../common/database'; +import { getDataFromKVPair } from '../utils/environment-utils'; import { fetchAllControlPlanes, fetchAllServices, @@ -11,6 +12,7 @@ import { type KonnectService, } from './api'; import { applyExpressionFields } from './expression-parser'; +import { getKonnectDeploymentType } from './transform'; import { buildRequestName, deriveProxyVarDefaults, @@ -562,8 +564,12 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project: }); if (newKvPairs.length > 0 || updatedExisting.some((kv, i) => kv !== existingKvPairs[i])) { + const finalKvPairData = [...updatedExisting, ...newKvPairs]; + const { data, dataPropertyOrder } = getDataFromKVPair(finalKvPairData); await insoservices.environment.update(projectEnv, { - kvPairData: [...updatedExisting, ...newKvPairs], + kvPairData: finalKvPairData, + data, + dataPropertyOrder, }); } @@ -598,10 +604,15 @@ async function syncControlPlane( // Upsert project for this control plane let project = existingProjectsByKonnectId.get(controlPlane.id); if (project) { - if (project.name !== controlPlane.name || project.konnectClusterType !== controlPlane.config.cluster_type) { + if ( + project.name !== controlPlane.name || + project.konnectClusterType !== controlPlane.config.cluster_type || + getKonnectDeploymentType(controlPlane) !== project.konnectDeploymentType + ) { project = await insoservices.project.update(project, { name: controlPlane.name, konnectClusterType: controlPlane.config.cluster_type, + konnectDeploymentType: getKonnectDeploymentType(controlPlane), }); acc.controlPlaneCounts.updated++; } @@ -611,6 +622,7 @@ async function syncControlPlane( name: controlPlane.name, konnectControlPlaneId: controlPlane.id, konnectClusterType: controlPlane.config.cluster_type, + konnectDeploymentType: getKonnectDeploymentType(controlPlane), }); existingProjectsByKonnectId.set(controlPlane.id, project); acc.controlPlaneCounts.created++; diff --git a/packages/insomnia/src/konnect/transform.ts b/packages/insomnia/src/konnect/transform.ts index 959af2b3d0..b842935401 100644 --- a/packages/insomnia/src/konnect/transform.ts +++ b/packages/insomnia/src/konnect/transform.ts @@ -1,4 +1,6 @@ -import type { KonnectProxyUrl, KonnectRoute } from './api'; +import type { KonnectDeploymentType } from 'insomnia-data'; + +import type { KonnectControlPlane, KonnectProxyUrl, KonnectRoute } from './api'; // ─── Template injection sanitisation ───────────────────────────────────────── @@ -12,16 +14,16 @@ function stripTemplateSyntax(value: string): string { let result = value; while (result !== prev) { prev = result; - result = result - .replace(/\{\{[\s\S]*?\}\}/g, '') - .replace(/\{%[\s\S]*?%\}/g, ''); + result = result.replace(/\{\{[\s\S]*?\}\}/g, '').replace(/\{%[\s\S]*?%\}/g, ''); } return result; } /** Strips template syntax from each item, filters empties, and returns null if nothing remains. */ function sanitizeStringArray(arr: string[] | null): string[] | null { - if (arr === null) { return null; } + if (arr === null) { + return null; + } const result = arr.map(stripTemplateSyntax).filter(s => s.trim() !== ''); return result.length > 0 ? result : null; } @@ -41,10 +43,10 @@ export function sanitizeRoute(route: KonnectRoute): KonnectRoute { hosts: sanitizeStringArray(route.hosts), headers: route.headers ? Object.fromEntries( - Object.entries(route.headers) - .map(([k, vs]): [string, string[]] => [stripTemplateSyntax(k), sanitizeStringArray(vs) ?? []]) - .filter(([k, vs]) => k.trim() !== '' && vs.length > 0), - ) + Object.entries(route.headers) + .map(([k, vs]): [string, string[]] => [stripTemplateSyntax(k), sanitizeStringArray(vs) ?? []]) + .filter(([k, vs]) => k.trim() !== '' && vs.length > 0), + ) : null, expression: route.expression !== null ? stripTemplateSyntax(route.expression) : null, }; @@ -79,7 +81,7 @@ export function extractRegionFromEndpoint(endpoint: string): string { /** * Names of the proxy environment variables Konnect sync manages. * On first sync, values are auto-filled from the control plane's `proxy_urls` - * when available; otherwise created as empty strings for manual entry. + * when available. * * - `proxy_host`: host (with port when non-standard), used in http/https/ws/wss URLs. * - `grpc_proxy_host`: host:port, used in grpc:// URLs. @@ -199,7 +201,9 @@ export function generatePathPlaceholder( // Validation: Check for leftover regex syntax const hasLeftoverRegex = /[()[\]*+?\\]/.test(path); if (hasLeftoverRegex) { - if (fallbackMode === 'keep') { return { path: regexString, pathParameters: [] }; } + if (fallbackMode === 'keep') { + return { path: regexString, pathParameters: [] }; + } return { path: '/:path', pathParameters: [{ name: 'path', value: '' }] }; } @@ -221,8 +225,12 @@ export function generatePathPlaceholder( * - regex path (Kong `~` prefix) → parsed via generatePathPlaceholder */ export function resolvePath(rawPath: string | null): ResolvedPath { - if (rawPath === null) { return { path: '', pathParameters: [] }; } - if (rawPath.startsWith('~')) { return generatePathPlaceholder(rawPath.slice(1)); } + if (rawPath === null) { + return { path: '', pathParameters: [] }; + } + if (rawPath.startsWith('~')) { + return generatePathPlaceholder(rawPath.slice(1)); + } return { path: rawPath, pathParameters: [] }; } @@ -230,15 +238,17 @@ export function routeDisplayName(route: { name: string | null; id: string }): st return route.name ?? `Route ${route.id}`; } -export function buildRequestName( - route: { name: string | null; paths: string[] | null; id: string }, -): string { +export function buildRequestName(route: { name: string | null; paths: string[] | null; id: string }): string { const rawPath = route.paths?.[0]; - if (rawPath === undefined) { return routeDisplayName(route); } + if (rawPath === undefined) { + return routeDisplayName(route); + } const resolved = resolvePath(rawPath).path; // If the regex was too complex to parse (fell back to '/:path'), use the raw // Kong path (including the '~' prefix) — it's more informative than '/:path'. - if (resolved === '/:path') { return rawPath; } + if (resolved === '/:path') { + return rawPath; + } return resolved || routeDisplayName(route); } @@ -282,7 +292,9 @@ export function pathParametersChanged( existing: { name: string; value: string }[], incoming: { name: string; value: string }[], ): boolean { - if (existing.length !== incoming.length) { return true; } + if (existing.length !== incoming.length) { + return true; + } return existing.some((p, i) => p.name !== incoming[i].name); } @@ -305,9 +317,112 @@ export function konnectHeadersChanged( for (const h of existing) { const expected = incomingByName.get(h.name); if (expected !== undefined) { - if (h.value !== expected) { return true; } + if (h.value !== expected) { + return true; + } matched++; } } return matched !== incoming.length; } + +export function getKonnectDeploymentType(controlPlane: KonnectControlPlane): KonnectDeploymentType | null { + const controlPlaneType = controlPlaneConfigToControlPlaneType({ + cluster_type: controlPlane.config.cluster_type as keyof typeof CLUSTER_TYPE_TO_CP_TYPE_MAP, + cloud_gateway: controlPlane.config.cloud_gateway, + }); + + switch (controlPlaneType) { + case ControlPlaneType.K8SIngressController: { + return 'k8sIngressController'; + } + case ControlPlaneType.Cloud: { + return 'dedicatedCloud'; + } + case ControlPlaneType.Serverless: { + return 'serverless'; + } + case ControlPlaneType.GroupWithCloudDataPlanes: + case ControlPlaneType.GroupWithOnPremDataPlanes: { + return 'group'; + } + case ControlPlaneType.ServerlessV1: { + return 'serverless'; + } + default: { + return 'selfManaged'; + } + } +} + +enum ControlPlaneType { + Hybrid = 'CONTROL_PLANE_TYPE_HYBRID', // self managed + Cloud = 'CONTROL_PLANE_TYPE_CLOUD', // Dedicated cloud + K8SIngressController = 'CONTROL_PLANE_TYPE_K8S_INGRESS_CONTROLLER', // KIC + /** + * Group of hybrid-type control planes, on-prem data planes can connect to this control plane group + */ + GroupWithOnPremDataPlanes = 'CONTROL_PLANE_TYPE_GROUP_WITH_ON_PREM_DATA_PLANES', + /** + * Group of hybrid-type control planes, cloud data planes can be created and managed by this control plane group + */ + GroupWithCloudDataPlanes = 'CONTROL_PLANE_TYPE_GROUP_WITH_CLOUD_DATA_PLANES', + Serverless = 'CONTROL_PLANE_TYPE_SERVERLESS', // Serverless.v0 deployed on fly.io + ServerlessV1 = 'CONTROL_PLANE_TYPE_SERVERLESS_V1', // Serverless.v1 (previously HVC) + // NativeEventProxy = 'CONTROL_PLANE_TYPE_KAFKA_NATIVE_EVENT_PROXY', // KNEP is deprecated in GM, DO NOT add it back +} + +const ControlPlaneClusterTypeEnum = { + ControlPlane: 'CLUSTER_TYPE_CONTROL_PLANE', + K8SIngressController: 'CLUSTER_TYPE_K8S_INGRESS_CONTROLLER', + ControlPlaneGroup: 'CLUSTER_TYPE_CONTROL_PLANE_GROUP', + Serverless: 'CLUSTER_TYPE_SERVERLESS', + HttpGateway: 'CLUSTER_TYPE_HTTP_GATEWAY', + EventGateway: 'CLUSTER_TYPE_EVENT_GATEWAY', + KafkaNativeEventProxy: 'CLUSTER_TYPE_KAFKA_NATIVE_EVENT_PROXY', + CloudApiGateway: 'CLUSTER_TYPE_CLOUD_API_GATEWAY', + ServerlessV1: 'CLUSTER_TYPE_SERVERLESS_V1', +}; + +const CLUSTER_TYPE_TO_CP_TYPE_MAP = { + [ControlPlaneClusterTypeEnum.ControlPlane]: { false: ControlPlaneType.Hybrid, true: ControlPlaneType.Cloud }, + [ControlPlaneClusterTypeEnum.K8SIngressController]: { false: ControlPlaneType.K8SIngressController }, + [ControlPlaneClusterTypeEnum.ControlPlaneGroup]: { + false: ControlPlaneType.GroupWithOnPremDataPlanes, + true: ControlPlaneType.GroupWithCloudDataPlanes, + }, + [ControlPlaneClusterTypeEnum.Serverless]: { false: ControlPlaneType.Serverless }, + [ControlPlaneClusterTypeEnum.ServerlessV1]: { true: ControlPlaneType.ServerlessV1 }, + // Placeholders for other cluster types + [ControlPlaneClusterTypeEnum.CloudApiGateway]: { true: ControlPlaneType.ServerlessV1 }, // TODO: remove this when CLUSTER_TYPE_SERVERLESS_V1 is accepted by the API (KHCP-19640) + [ControlPlaneClusterTypeEnum.HttpGateway]: { false: null }, + [ControlPlaneClusterTypeEnum.EventGateway]: { false: null }, + [ControlPlaneClusterTypeEnum.KafkaNativeEventProxy]: { false: null }, // KNEP is deprecated, DO NOT map it to any CP type +} as const; + +type ControlPlaneConfigToControlPlaneType< + T extends keyof typeof CLUSTER_TYPE_TO_CP_TYPE_MAP, + C extends boolean, +> = `${C}` extends keyof (typeof CLUSTER_TYPE_TO_CP_TYPE_MAP)[T] + ? (typeof CLUSTER_TYPE_TO_CP_TYPE_MAP)[T][`${C}`] + : never; + +type NeverToNull = T extends never ? null : T; + +function controlPlaneConfigToControlPlaneType< + T extends keyof typeof CLUSTER_TYPE_TO_CP_TYPE_MAP, + C extends boolean, +>(config: { cluster_type: T; cloud_gateway: C }): NeverToNull> { + type ReturnType = NeverToNull>; + const { cluster_type: clusterType, cloud_gateway: isCloudGateway } = config; + const subMap = CLUSTER_TYPE_TO_CP_TYPE_MAP[clusterType]; + if (subMap && Object.hasOwnProperty.call(subMap, `${isCloudGateway}`)) { + return subMap[`${isCloudGateway}` as keyof typeof subMap] as ReturnType; + } + // this should never happen, but just in case + console.error( + `ControlPlaneConfigToControlPlaneType: invalid clusterType ${clusterType} or cloud_gateway ${isCloudGateway}`, + ); + + return null as ReturnType; +} diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 27be1a8b40..78c887dd8c 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -1,7 +1,7 @@ import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; import type { GitRepository, Project, WorkspaceScope } from 'insomnia-data'; import { models } from 'insomnia-data'; -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Button, GridList, @@ -252,16 +252,31 @@ const Component = () => { }, })); - const createNewCollection = (source: string) => - setNewWorkspaceModalState({ scope: 'collection', isOpen: true, source }); - const createNewDocument = (source: string) => setNewWorkspaceModalState({ scope: 'design', isOpen: true, source }); - const createNewMockServer = (source: string) => - canCreateMockServer && setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true, source }); - const createNewGlobalEnvironment = (source: string) => - setNewWorkspaceModalState({ scope: 'environment', isOpen: true, source }); - const createNewMcpClient = (source: string) => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true, source }); + const canCreateMockServer = activeProject?._id; - const createNewCollectionWithRequest = () => { + const createNewCollection = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'collection', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + const createNewDocument = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'design', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + const createNewMockServer = useCallback( + (source: string) => + canCreateMockServer && setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true, source }), + [canCreateMockServer, setNewWorkspaceModalState], + ); + const createNewGlobalEnvironment = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'environment', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + const createNewMcpClient = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + + const createNewCollectionWithRequest = useCallback(() => { if (!activeProject) { return; } @@ -274,57 +289,67 @@ const Component = () => { withRequest: true, source: 'home-page', }); - }; + }, [activeProject, createNewWorkspaceFetcher, organizationId, projectId]); - const canCreateMockServer = activeProject?._id; - - const createInProjectActionList: { - id: string; - name: string; - icon: IconProp; - scope: WorkspaceScope; - action: () => void; - }[] = [ + const createInProjectActionList = useMemo< { - id: 'new-collection', - name: 'Collection', - icon: 'bars', - action: () => createNewCollection('navbar'), - scope: 'collection', - }, - { - id: 'new-document', - name: 'Document', - icon: 'file', - action: () => createNewDocument('navbar'), - scope: 'design', - }, - { - id: 'new-mcp-client', - name: 'MCP Client', - scope: 'mcp', - icon: ['fac', 'mcp'] as unknown as IconProp, - action: () => createNewMcpClient('navbar'), - }, - ...(canCreateMockServer - ? [ - { - id: 'new-mock-server', - name: 'Mock Server', - scope: 'mock-server' as WorkspaceScope, - icon: 'server' as IconName, - action: () => createNewMockServer('navbar'), - }, - ] - : []), - { - id: 'new-environment', - name: 'Environment', - icon: 'code', - action: () => createNewGlobalEnvironment('navbar'), - scope: 'environment', - }, - ]; + id: string; + name: string; + icon: IconProp; + scope: WorkspaceScope; + action: () => void; + }[] + >( + () => [ + { + id: 'new-collection', + name: 'Collection', + icon: 'bars', + action: () => createNewCollection('navbar'), + scope: 'collection', + }, + { + id: 'new-document', + name: 'Document', + icon: 'file', + action: () => createNewDocument('navbar'), + scope: 'design', + }, + { + id: 'new-mcp-client', + name: 'MCP Client', + scope: 'mcp', + icon: ['fac', 'mcp'] as unknown as IconProp, + action: () => createNewMcpClient('navbar'), + }, + ...(canCreateMockServer + ? [ + { + id: 'new-mock-server', + name: 'Mock Server', + scope: 'mock-server' as WorkspaceScope, + icon: 'server' as IconName, + action: () => createNewMockServer('navbar'), + }, + ] + : []), + { + id: 'new-environment', + name: 'Environment', + icon: 'code', + action: () => createNewGlobalEnvironment('navbar'), + scope: 'environment', + }, + ], + [ + canCreateMockServer, + createNewCollection, + createNewDocument, + createNewGlobalEnvironment, + createNewMcpClient, + createNewMockServer, + ], + ); const isRemoteProjectInconsistent = activeProject && models.project.isRemoteProject(activeProject) && !storageRules.enableCloudSync; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx index 0169365588..70136bd17e 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx @@ -48,6 +48,23 @@ export async function clientAction({ params }: Route.ClientActionArgs) { project.gitRepositoryId && reportGitProjectCount(organizationId, sessionId); + // If the deleted project is a Konnect project, navigate to another Konnect project + if (project.konnectControlPlaneId) { + const remainingKonnectProjects = (await services.project.list({ organizationId })).filter( + p => p.konnectControlPlaneId != null && p._id !== projectId, + ); + + if (remainingKonnectProjects.length > 0) { + const targetProject = remainingKonnectProjects[0]; + return redirect( + href('/organization/:organizationId/project/:projectId', { + organizationId, + projectId: targetProject._id, + }), + ); + } + } + // When redirect to `/organizations/:organizationId`, it sometimes doesn't reload the index loader, so manually redirect to the initial route for the organization const initialOrganizationRoute = await getInitialRouteForOrganization({ organizationId }); return redirect(initialOrganizationRoute); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index a2cd8c9ad8..ce4e9e1dc2 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -76,6 +76,28 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const project = await services.project.get(projectId); if (!project) { + // When a project is not found (e.g., after deletion), check if user was on Konnect tab + // and try to redirect to another Konnect project to avoid switching tabs + const storedTab = localStorage.getItem(`${organizationId}:sidebar-tab`); + if (storedTab) { + try { + const parsedTab = JSON.parse(storedTab); + if (parsedTab === 'konnect') { + const allProjects = await services.project.list({ organizationId }); + const konnectProjects = models.project.sortProjects(allProjects.filter(p => p.konnectControlPlaneId != null)); + if (konnectProjects.length > 0) { + return redirect( + href('/organization/:organizationId/project/:projectId', { + organizationId, + projectId: konnectProjects[0]._id, + }), + ); + } + } + } catch { + // ignore parse errors + } + } return redirect(href('/organization/:organizationId', { organizationId })); } diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx index 14d31c67c7..19e7ad1998 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx @@ -144,16 +144,22 @@ export const ProjectDropdown: FC = ({ name: 'Delete', icon: 'trash', action: (projectId: string, projectName: string) => { + let title = 'Delete Project'; let message = `You are deleting the project "${projectName}" that may have collaborators. As a result of this, the project will be permanently deleted for every collaborator of the organization. Do you really want to continue?`; + let yesText = 'Delete'; - if (models.project.isGitProject(project)) { + if (project.konnectControlPlaneId) { + title = 'Remove Project'; + message = `Do you wish to remove your local copy of the "${projectName}" project? This will not affect anything in Konnect, or any other users.`; + yesText = 'Remove'; + } else if (models.project.isGitProject(project)) { message = `You are deleting the Git project "${projectName}". Deleting this project will not delete the remote repository but all your local changes will be lost. Do you really want to continue?`; } showModal(AskModal, { - title: 'Delete Project', + title, message, - yesText: 'Delete', + yesText, noText: 'Cancel', color: 'danger', onDone: async (isYes: boolean) => { diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx index a795767e7b..61a98efcaa 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -440,7 +440,9 @@ export const SidebarWorkspaceDropdown = ({ {({ close }) => (
- Delete {getWorkspaceLabel(workspace).singular} + + {project.konnectControlPlaneId ? 'Remove' : 'Delete'} {getWorkspaceLabel(workspace).singular} +
diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index 015eef427a..95ad2e59b0 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -276,7 +276,9 @@ export const WorkspaceCardDropdown: FC = props => { {({ close }) => (
- Delete {getWorkspaceLabel(workspace).singular} + + {project.konnectControlPlaneId ? 'Remove' : 'Delete'} {getWorkspaceLabel(workspace).singular} +
diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx deleted file mode 100644 index 71f514b91b..0000000000 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx +++ /dev/null @@ -1,502 +0,0 @@ -import type { IconName } from '@fortawesome/fontawesome-svg-core'; -import { - exportGlobalEnvironmentToFile, - exportMcpClientToFile, - exportMockServerToFile, -} from 'insomnia/src/ui/components/settings/import-export'; -import type { Workspace } from 'insomnia-data'; -import { models } from 'insomnia-data'; -import type { PlatformKeyCombinations } from 'insomnia-data/common'; -import { invariant } from 'insomnia-data/common'; -import { type FC, type ReactNode, useCallback, useEffect, useState } from 'react'; -import { - Button, - Collection, - Dialog, - Header, - Heading, - Label, - Menu, - MenuItem, - MenuSection, - MenuTrigger, - Modal, - ModalOverlay, - Popover, - Radio, - RadioGroup, -} from 'react-aria-components'; -import { href, useNavigate, useParams } from 'react-router'; - -import { useWorkspaceDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.delete'; -import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update'; - -import { getProductName } from '../../../common/constants'; -import { database as db } from '../../../common/database'; -import { getWorkspaceLabel } from '../../../common/get-workspace-label'; -import type { SerializableActionMeta } from '../../../plugins/bridge-types'; -import { plugins } from '../../../plugins/renderer-bridge'; -import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -import { useMockServerGenerateRequestCollectionActionFetcher } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.generate-request-collection'; -import { AnalyticsEvent } from '../../analytics'; -import { DropdownHint } from '../base/dropdown/dropdown-hint'; -import { Icon } from '../icon'; -import { useDocBodyKeyboardShortcuts } from '../keydown-binder'; -import { showError, showModal } from '../modals'; -import { ExportRequestsModal } from '../modals/export-requests-modal'; -import { ImportModal } from '../modals/import-modal/import-modal'; -import { PromptModal } from '../modals/prompt-modal'; -import { WorkspaceDuplicateModal } from '../modals/workspace-duplicate-modal'; -import { WorkspaceSettingsModal } from '../modals/workspace-settings-modal'; - -export const WorkspaceDropdown: FC<{}> = () => { - const { organizationId, projectId, workspaceId } = useParams() as { - organizationId: string; - projectId: string; - workspaceId: string; - }; - invariant(organizationId, 'Expected organizationId'); - const { activeWorkspace, activeWorkspaceMeta, activeProject, activeMockServer } = useWorkspaceLoaderData()!; - - const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); - const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - - const updateWorkspaceFetcher = useWorkspaceUpdateActionFetcher(); - const [isDeleteRemoteWorkspaceModalOpen, setIsDeleteRemoteWorkspaceModalOpen] = useState(false); - const deleteWorkspaceFetcher = useWorkspaceDeleteActionFetcher(); - const [actionPlugins, setActionPlugins] = useState([]); - const [loadingActions, setLoadingActions] = useState>({}); - const navigate = useNavigate(); - const generateCollectionFetcher = useMockServerGenerateRequestCollectionActionFetcher(); - - // after duplicate workspace, close the modal - useEffect(() => { - setIsDuplicateModalOpen(false); - }, [workspaceId]); - - useDocBodyKeyboardShortcuts({ - workspace_showSettings: () => setIsSettingsModalOpen(true), - }); - - const handlePluginClick = useCallback( - async ({ pluginName, label }: SerializableActionMeta, workspace: Workspace) => { - setLoadingActions({ ...loadingActions, [label]: true }); - try { - const docs = await db.getWithDescendants(workspace, [models.request.type]); - const requests = docs.filter(models.request.isRequest).filter(doc => !doc.isPrivate); - const requestGroups = docs.filter(models.requestGroup.isRequestGroup); - await plugins.executeAction({ - type: 'workspace', - pluginName, - label, - projectId: activeProject._id, - domainData: { workspace, requests, requestGroups }, - }); - } catch (err) { - showError({ - title: 'Plugin Action Failed', - error: err, - }); - } - setLoadingActions({ ...loadingActions, [label]: false }); - }, - [activeProject._id, loadingActions], - ); - - const handleDropdownOpen = useCallback(async () => { - const actionPlugins = await plugins.getWorkspaceActions(); - setActionPlugins(actionPlugins); - }, []); - - const isScratchpadWorkspace = models.workspace.isScratchpad(activeWorkspace); - const scratchpadActionList: { - name: string; - id: string; - icon: IconName; - items: { - id: string; - name: string; - icon: ReactNode; - hint?: PlatformKeyCombinations; - action: () => void; - }[]; - }[] = [ - { - name: 'Actions', - id: 'Actions', - icon: 'cog', - items: [ - { - id: 'Import', - name: 'Import', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.importStarted, - properties: { - source: `scratchpad-${activeWorkspace.scope}-menu`, - }, - }); - - setIsImportModalOpen(true); - }, - }, - { - id: 'Export', - name: 'Export', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.exportStarted, - properties: { - source: `scratchpad-${activeWorkspace.scope}-menu`, - }, - }); - if (activeWorkspace.scope === 'mock-server') { - return exportMockServerToFile(activeWorkspace); - } - - if (activeWorkspace.scope === 'environment') { - return exportGlobalEnvironmentToFile(activeWorkspace); - } - - return setIsExportModalOpen(true); - }, - }, - ], - }, - ]; - - const workspaceActionsList: { - name: string; - id: string; - icon: IconName; - items: { - id: string; - name: string; - icon: ReactNode; - hint?: PlatformKeyCombinations; - action: () => void; - }[]; - }[] = [ - ...(models.workspace.isMcp(activeWorkspace) - ? [] - : [ - { - name: 'Import', - id: 'import', - icon: 'cog' as IconName, - items: [ - { - id: 'from-file', - name: 'From File', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.importStarted, - properties: { - source: `${activeWorkspace.scope}-menu`, - }, - }); - setIsImportModalOpen(true); - }, - }, - ], - }, - { - name: 'Runner', - id: 'runner', - icon: 'circle-play' as const, - items: [ - { - id: 'run', - name: 'Run Collection', - icon: , - action: () => { - navigate( - `/organization/${organizationId}/project/${activeWorkspace.parentId}/workspace/${activeWorkspace._id}/debug/runner?folder=`, - ); - }, - }, - ], - }, - ]), - { - name: 'Actions', - id: 'actions', - icon: 'cog', - items: [ - ...(models.workspace.isMcp(activeWorkspace) - ? [] - : [ - { - id: 'duplicate', - name: 'Duplicate', - icon: , - action: () => setIsDuplicateModalOpen(true), - }, - ]), - { - id: 'rename', - name: 'Rename', - icon: , - action: () => - showModal(PromptModal, { - title: `Rename ${getWorkspaceLabel(activeWorkspace).singular}`, - defaultValue: activeWorkspace.name, - submitName: 'Rename', - selectText: true, - label: 'Name', - onComplete: name => - updateWorkspaceFetcher.submit({ - organizationId, - projectId: activeWorkspace.parentId, - patch: { name, workspaceId: activeWorkspace._id }, - }), - }), - }, - { - id: 'export', - name: 'Export', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.exportStarted, - properties: { - source: `${activeWorkspace.scope}-menu`, - }, - }); - - if (activeWorkspace.scope === 'mock-server') { - return exportMockServerToFile(activeWorkspace); - } - - if (activeWorkspace.scope === 'environment') { - return exportGlobalEnvironmentToFile(activeWorkspace); - } - - if (activeWorkspace.scope === 'mcp') { - return exportMcpClientToFile(activeWorkspace); - } - - return setIsExportModalOpen(true); - }, - }, - ...(activeWorkspace.scope === 'mock-server' - ? [ - { - id: 'generate-collection', - name: 'Generate Collection', - icon: , - action: () => { - generateCollectionFetcher.submit({ - organizationId, - projectId: activeWorkspace.parentId, - workspaceId: activeWorkspace._id, - }); - }, - }, - ] - : []), - { - id: 'settings', - name: 'Settings', - icon: , - action: () => setIsSettingsModalOpen(true), - }, - { - id: 'delete', - name: 'Delete', - icon: , - action: () => setIsDeleteRemoteWorkspaceModalOpen(true), - }, - ], - }, - ...(actionPlugins.length > 0 - ? [ - { - name: 'Plugins', - id: 'plugins', - icon: 'plug' as IconName, - items: actionPlugins.map(plugin => ({ - id: plugin.label, - name: plugin.label, - icon: , - action: () => handlePluginClick(plugin, activeWorkspace), - })), - }, - ] - : []), - ]; - const actionlist = isScratchpadWorkspace ? scratchpadActionList : workspaceActionsList; - return ( - <> - isOpen && handleDropdownOpen()}> - - - - actionlist - .find(i => i.items.find(a => a.id === key)) - ?.items.find(a => a.id === key) - ?.action() - } - items={actionlist} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" - > - {section => ( - -
- {section.name} -
- - {item => ( - - {item.icon} - {item.name} - {item.hint && } - - )} - -
- )} -
-
-
- {isDuplicateModalOpen && ( - setIsDuplicateModalOpen(false)} workspace={activeWorkspace} /> - )} - {isImportModalOpen && ( - setIsImportModalOpen(false)} - from={{ type: 'file' }} - projectName={activeProject.name ?? getProductName()} - workspaceName={activeWorkspace.name} - organizationId={organizationId} - defaultProjectId={projectId} - defaultWorkspaceId={workspaceId} - /> - )} - {isExportModalOpen && ( - setIsExportModalOpen(false)} /> - )} - {isSettingsModalOpen && ( - setIsSettingsModalOpen(false)} - /> - )} - {isDeleteRemoteWorkspaceModalOpen && ( - { - setIsDeleteRemoteWorkspaceModalOpen(false); - }} - isDismissable - className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" - > - { - setIsDeleteRemoteWorkspaceModalOpen(false); - }} - className="max-h-full w-full max-w-2xl rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" - > - - {({ close }) => ( -
-
- Delete {getWorkspaceLabel(activeWorkspace).singular} - -
- - -
-

- This will permanently delete the{' '} - {activeWorkspace?.name}{' '} - {getWorkspaceLabel(activeWorkspace).singular} -

- {models.project.isRemoteProject(activeProject) && ( - - -
- -
- Remove Local Copy -

The project will still exist on the Cloud.

-
-
- -
- Delete Permanently -

- The project will be deleted everywhere. You cannot undo this action. -

-
-
-
-
- )} -
- {deleteWorkspaceFetcher.data && deleteWorkspaceFetcher.data.error && ( -

{deleteWorkspaceFetcher.data.error}

- )} -
- -
-
-
- )} -
-
-
- )} - - ); -}; diff --git a/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx index 4cbb489fc2..69726c7397 100644 --- a/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx @@ -13,9 +13,11 @@ import { Icon } from '../icon'; export const KonnectSettingsModal = ({ onClose, syncKonnectProjectsAndNotifyRef, + onDisconnect, }: { onClose: () => void; syncKonnectProjectsAndNotifyRef: React.MutableRefObject<() => Promise>; + onDisconnect?: () => void; }) => { const { settings } = useRootLoaderData()!; const patchSettings = useSettingsPatcher(); @@ -66,6 +68,7 @@ export const KonnectSettingsModal = ({ patchSettings({ hasKonnectPat: true }); window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated }); syncKonnectProjectsAndNotifyRef.current(); + onClose(); } }; @@ -85,6 +88,7 @@ export const KonnectSettingsModal = ({ } await window.main.secretStorage.deleteSecret('konnectPat'); patchSettings({ hasKonnectPat: false }); + onDisconnect?.(); onClose(); } finally { setIsDisconnecting(false); diff --git a/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx deleted file mode 100644 index dae29982b5..0000000000 --- a/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { strings } from 'insomnia-data/common'; -import React, { useEffect, useRef, useState } from 'react'; -import { OverlayContainer } from 'react-aria'; - -import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -import { interceptAccessError } from '../../../sync/access-error'; -import { Button } from '../../components/themed-button'; -import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; -import { ModalBody } from '../base/modal-body'; -import { ModalHeader } from '../base/modal-header'; - -interface SyncArchiveVCSLike { - archiveProject: () => Promise; -} - -type Props = ModalProps & { - vcs: SyncArchiveVCSLike; -}; - -interface State { - error?: string; - workspaceName: string; -} - -export const SyncDeleteModal = ({ vcs, onHide }: Props) => { - const modalRef = useRef(null); - const [state, setState] = useState({ - error: '', - workspaceName: '', - }); - const { activeWorkspace } = useWorkspaceLoaderData()!; - - useEffect(() => { - modalRef.current?.show(); - }, []); - const onSubmit = async (event: React.SyntheticEvent) => { - event.preventDefault(); - try { - await interceptAccessError({ - action: 'delete', - callback: async () => await vcs.archiveProject(), - resourceName: state.workspaceName, - resourceType: strings.collection.singular.toLowerCase(), - }); - modalRef.current?.hide(); - onHide?.(); - } catch (err) { - setState(state => ({ - ...state, - error: err.message, - })); - } - }; - const { error, workspaceName } = state; - - return ( - - - Delete {strings.collection.singular} - - {error &&

{error}

} -

- This will permanently delete the{' '} - {{activeWorkspace?.name}}{' '} - {strings.collection.singular.toLowerCase()} remotely. -

-

- Please type {{activeWorkspace?.name}} to confirm. -

-
-
- setState(state => ({ ...state, workspaceName: event.target.value }))} - value={workspaceName} - /> - -
-
-
-
-
- ); -}; diff --git a/packages/insomnia/src/ui/components/project/project-settings-form.tsx b/packages/insomnia/src/ui/components/project/project-settings-form.tsx index 25718675e4..02d96d520e 100644 --- a/packages/insomnia/src/ui/components/project/project-settings-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-settings-form.tsx @@ -248,11 +248,25 @@ export const ProjectSettingsForm: FC = ({ className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden" /> - setStorageType(v as 'local' | 'remote' | 'git')} - /> + {project?.konnectControlPlaneId ? ( +
+ +
+
+ + Synced from Konnect +
+
+
+ ) : ( + setStorageType(v as 'local' | 'remote' | 'git')} + /> + )} void; +} + +export const KonnectEnvOnboarding = ({ triggerElement, onDismiss }: KonnectEnvOnboardingProps) => { + const popoverRef = useRef(null); + + useEffect(() => { + if (!triggerElement) return; + + const updatePosition = () => { + if (!popoverRef.current) return; + const triggerRect = triggerElement.getBoundingClientRect(); + const popover = popoverRef.current; + popover.style.top = `${triggerRect.top}px`; + popover.style.left = `${triggerRect.right + 8}px`; + }; + + updatePosition(); + + const observer = new ResizeObserver(updatePosition); + observer.observe(triggerElement); + + return () => observer.disconnect(); + }, [triggerElement]); + + return ( +
+
+

+ Almost ready! Just set your proxy host for each control plane +

+ +
+

+ Your requests have been automatically set with a{' '} + proxy_host{' '} + environment variable for quick testing against different deployment stages. Enter it here before testing your + gateway routes. +

+ +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg new file mode 100644 index 0000000000..4f868a12ba --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg new file mode 100644 index 0000000000..edd39aa676 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg new file mode 100644 index 0000000000..94beb267a9 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg @@ -0,0 +1,13 @@ +My custom title + + + + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx new file mode 100644 index 0000000000..d7f50a0f67 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx @@ -0,0 +1,28 @@ +import type { KonnectDeploymentType } from 'insomnia-data'; + +import { Icon } from '../../../icon'; +import dedicatedCloudIcon from './dedicated-cloud.svg'; +import groupIcon from './group.svg'; +import k8sIngressControllerIcon from './k8s-ingress-controller.svg'; +import selfManagedIcon from './self-managed.svg'; +import serverlessIcon from './serverless.svg'; + +const konnectDeploymentTypeToIcon: Record = { + selfManaged: selfManagedIcon, + serverless: serverlessIcon, + dedicatedCloud: dedicatedCloudIcon, + group: groupIcon, + k8sIngressController: k8sIngressControllerIcon, +}; + +export const KonnectProjectIcon = ({ + konnectDeploymentType, +}: { + konnectDeploymentType?: KonnectDeploymentType | null; +}) => { + const icon = konnectDeploymentType ? konnectDeploymentTypeToIcon[konnectDeploymentType] : undefined; + if (!icon) { + return ; + } + return ; +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg new file mode 100644 index 0000000000..240821f764 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg new file mode 100644 index 0000000000..13408cf372 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 22c3459577..a62de1047f 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -14,7 +14,18 @@ import { useRef, useState, } from 'react'; -import { Button, GridList, GridListItem, Input, SearchField, Tab, TabList, Tabs } from 'react-aria-components'; +import { + Button, + GridList, + GridListItem, + Input, + SearchField, + Tab, + TabList, + Tabs, + Tooltip, + TooltipTrigger, +} from 'react-aria-components'; import { useNavigate, useParams, useSearchParams } from 'react-router'; import * as reactUse from 'react-use'; @@ -33,6 +44,7 @@ import { showModal } from '~/ui/components/modals'; import { AskModal } from '~/ui/components/modals/ask-modal'; import { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-modal'; import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node'; +import { KonnectEnvOnboarding } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding'; import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro'; import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; @@ -40,6 +52,7 @@ import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; +import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import insomniaLogo from '~/ui/images/insomnia-logo.svg'; import { isPrimaryClickModifier } from '~/ui/utils'; @@ -73,6 +86,29 @@ export interface ProjectNavigationSidebarHandle { export type ProjectNavigationSidebarTabId = 'projects' | 'konnect'; +function LastSyncedLabel({ lastSyncedAt }: { lastSyncedAt: number | null }) { + return lastSyncedAt + ? `Last synced: ${getRelativeTimeString(lastSyncedAt, Date.now())}` + : 'Not yet synced'; +} + +function getRelativeTimeString(timestamp: number, now: number = Date.now()): string { + const seconds = Math.floor((now - timestamp) / 1000); + if (seconds < 60) { + return `${seconds}s ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ${minutes % 60}m ago`; + } + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h ago`; +} + const SidebarSearchField = ({ value, isDisabled, @@ -201,6 +237,10 @@ const ProjectNavigationSidebarInner = ( ); const isProjectTabActive = activeTab === 'projects'; const { syncing, progress, startSync, cancelSync } = useKonnectSync(); + const [lastSyncedAt, setLastSyncedAt] = reactUse.useLocalStorage( + `${organizationId}:konnect-last-synced-at`, + null, + ); const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId); const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null); @@ -312,10 +352,39 @@ const ProjectNavigationSidebarInner = ( }, [organizationId, cloudSyncProjectIdsKey]); const syncKonnectProjectsAndNotify = async () => { + const isFirstSync = lastSyncedAt == null; const result = await startSync(organizationId); setLastSyncResult(result ?? null); setShowSyncDetails(false); setCopiedReason(null); + if (result?.success) { + setLastSyncedAt(Date.now()); + // Navigate to and expand the first Konnect project after a successful sync + const allProjects = await services.project.list({ organizationId }); + const sortedKonnectProjects = models.project.sortProjects( + allProjects.filter(p => p.konnectControlPlaneId != null), + ); + const firstKonnectProject = sortedKonnectProjects[0]; + if (firstKonnectProject) { + const workspaces = await services.workspace.findByParentId(firstKonnectProject._id); + const envWorkspace = workspaces.find(w => w.scope === 'environment'); + if (envWorkspace) { + // Show environment onboarding after first successful sync + if (isFirstSync) { + setOnboardingEnvWorkspaceId(envWorkspace._id); + } + navigate( + `/organization/${organizationId}/project/${firstKonnectProject._id}/workspace/${envWorkspace._id}/environment`, + ); + } else { + navigate(`/organization/${organizationId}/project/${firstKonnectProject._id}`); + } + setExpandedProjectAndWorkspaceIds(prev => { + const ids = prev || []; + return ids.includes(firstKonnectProject._id) ? ids : [...ids, firstKonnectProject._id]; + }); + } + } }; syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify; @@ -894,6 +963,13 @@ const ProjectNavigationSidebarInner = ( const [lastSyncResult, setLastSyncResult] = useState(null); const [showSyncDetails, setShowSyncDetails] = useState(false); const [copiedReason, setCopiedReason] = useState(null); + const [onboardingEnvWorkspaceId, setOnboardingEnvWorkspaceId] = useState(null); + const [envOnboardingNode, setEnvOnboardingNode] = useState(null); + + const dismissEnvOnboarding = useCallback(() => { + setOnboardingEnvWorkspaceId(null); + }, []); + const skippedRoutesByReason = useMemo(() => { const map = new Map(); for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) { @@ -938,14 +1014,22 @@ const ProjectNavigationSidebarInner = ( ) : ( - + + + + + + )}
); }; @@ -1223,12 +1318,13 @@ export const ProjectNavigationSidebar = forwardRef void }) => { const { organizationId } = useParams() as { organizationId: string }; const isScratchPad = models.organization.isScratchpadOrganizationId(organizationId); + const { features } = useOrganizationPermissions(); return (
- + {doc.konnectControlPlaneId ? ( + + ) : ( + + )} {projectName}
{presence.length > 0 && } diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx index 6c72cfca11..203aea1e59 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { type Ref, useState } from 'react'; import { Button } from 'react-aria-components'; import type { SortOrder } from '~/common/constants'; @@ -21,9 +21,18 @@ interface WorkspaceNodeProps { sortOrder: SortOrder; onSortOrderChange: (newSortOrder: SortOrder) => void; + highlighted?: boolean; + nodeRef?: Ref | ((node: HTMLDivElement | null) => void); } -export const WorkspaceNode = ({ item, sortOrder, onToggle, onSortOrderChange }: WorkspaceNodeProps) => { +export const WorkspaceNode = ({ + item, + sortOrder, + onToggle, + onSortOrderChange, + highlighted, + nodeRef, +}: WorkspaceNodeProps) => { const { doc, collapsed, project, organizationId } = item; const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc; const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); @@ -31,7 +40,8 @@ export const WorkspaceNode = ({ item, sortOrder, onToggle, onSortOrderChange }: return (