mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-04 03:42:20 -04:00
Improve Konnect sync UX [INS-2697] (#10038)
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <ryan.willis@konghq.com>
Co-authored-by: Vivek Thuravupala <2700229+godfrzero@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 5c08a0383c)
This commit is contained in:
@@ -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<Project, '_id'>) => !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<BaseModel, 'type'>): 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<Project> {
|
||||
return {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<void>((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<PatValidationResult> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function* fetchAllControlPlanes(
|
||||
pat: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<KonnectControlPlane[]> {
|
||||
export async function* fetchAllControlPlanes(pat: string, signal?: AbortSignal): AsyncGenerator<KonnectControlPlane[]> {
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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> = 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<ControlPlaneConfigToControlPlaneType<T, C>> {
|
||||
type ReturnType = NeverToNull<ControlPlaneConfigToControlPlaneType<T, C>>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -144,16 +144,22 @@ export const ProjectDropdown: FC<Props> = ({
|
||||
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) => {
|
||||
|
||||
@@ -440,7 +440,9 @@ export const SidebarWorkspaceDropdown = ({
|
||||
{({ close }) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Heading className="text-2xl">Delete {getWorkspaceLabel(workspace).singular}</Heading>
|
||||
<Heading className="text-2xl">
|
||||
{project.konnectControlPlaneId ? 'Remove' : 'Delete'} {getWorkspaceLabel(workspace).singular}
|
||||
</Heading>
|
||||
<Button
|
||||
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
onPress={close}
|
||||
@@ -459,9 +461,20 @@ export const SidebarWorkspaceDropdown = ({
|
||||
<input type="hidden" name="workspaceId" value={workspaceId} />
|
||||
<div>
|
||||
<p className="line-clamp-5">
|
||||
This will permanently delete the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{workspaceName}</strong>{' '}
|
||||
{getWorkspaceLabel(workspace).singular}
|
||||
{project.konnectControlPlaneId ? (
|
||||
<>
|
||||
Do you wish to remove your local copy of the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{workspaceName}</strong>{' '}
|
||||
{getWorkspaceLabel(workspace).singular}? This will not affect anything in Konnect, or any
|
||||
other users.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will permanently delete the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{workspaceName}</strong>{' '}
|
||||
{getWorkspaceLabel(workspace).singular}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{models.project.isRemoteProject(project) && (
|
||||
<RadioGroup name="localOnly" defaultValue="false" className="mb-2 flex flex-col gap-2">
|
||||
@@ -502,7 +515,7 @@ export const SidebarWorkspaceDropdown = ({
|
||||
aria-label="Delete Workspace"
|
||||
className="rounded-xs border border-solid border-(--hl-md) bg-(--color-danger) px-3 py-2 text-(--color-font-danger) transition-colors hover:bg-(--color-danger)/90 hover:no-underline"
|
||||
>
|
||||
Delete
|
||||
{project.konnectControlPlaneId ? 'Remove' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</deleteWorkspaceFetcher.Form>
|
||||
|
||||
@@ -276,7 +276,9 @@ export const WorkspaceCardDropdown: FC<Props> = props => {
|
||||
{({ close }) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Heading className="text-2xl">Delete {getWorkspaceLabel(workspace).singular}</Heading>
|
||||
<Heading className="text-2xl">
|
||||
{project.konnectControlPlaneId ? 'Remove' : 'Delete'} {getWorkspaceLabel(workspace).singular}
|
||||
</Heading>
|
||||
<Button
|
||||
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
onPress={close}
|
||||
@@ -295,9 +297,20 @@ export const WorkspaceCardDropdown: FC<Props> = props => {
|
||||
<input type="hidden" name="workspaceId" value={workspace._id} />
|
||||
<div>
|
||||
<p className="line-clamp-5">
|
||||
This will permanently delete the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{workspace?.name}</strong>{' '}
|
||||
{getWorkspaceLabel(workspace).singular}
|
||||
{project.konnectControlPlaneId ? (
|
||||
<>
|
||||
Do you wish to remove your local copy of the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{workspace?.name}</strong>{' '}
|
||||
{getWorkspaceLabel(workspace).singular}? This will not affect anything in Konnect, or any
|
||||
other users.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will permanently delete the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{workspace?.name}</strong>{' '}
|
||||
{getWorkspaceLabel(workspace).singular}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{models.project.isRemoteProject(project) && (
|
||||
<RadioGroup name="localOnly" defaultValue="true" className="mb-2 flex flex-col gap-2">
|
||||
@@ -338,7 +351,7 @@ export const WorkspaceCardDropdown: FC<Props> = props => {
|
||||
aria-label="Delete Workspace"
|
||||
className="rounded-xs border border-solid border-(--hl-md) bg-(--color-danger) px-3 py-2 text-(--color-font-danger) transition-colors hover:bg-(--color-danger)/90 hover:no-underline"
|
||||
>
|
||||
Delete
|
||||
{project.konnectControlPlaneId ? 'Remove' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</deleteWorkspaceFetcher.Form>
|
||||
|
||||
@@ -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<SerializableActionMeta[]>([]);
|
||||
const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({});
|
||||
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: <Icon icon="file-import" />,
|
||||
action: () => {
|
||||
window.main.trackAnalyticsEvent({
|
||||
event: AnalyticsEvent.importStarted,
|
||||
properties: {
|
||||
source: `scratchpad-${activeWorkspace.scope}-menu`,
|
||||
},
|
||||
});
|
||||
|
||||
setIsImportModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Export',
|
||||
name: 'Export',
|
||||
icon: <Icon icon="file-export" />,
|
||||
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: <Icon icon="file-import" />,
|
||||
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: <Icon icon="circle-play" />,
|
||||
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: <Icon icon="bars" />,
|
||||
action: () => setIsDuplicateModalOpen(true),
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: 'rename',
|
||||
name: 'Rename',
|
||||
icon: <Icon icon="pen-to-square" />,
|
||||
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: <Icon icon="file-export" />,
|
||||
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: <Icon icon="code" />,
|
||||
action: () => {
|
||||
generateCollectionFetcher.submit({
|
||||
organizationId,
|
||||
projectId: activeWorkspace.parentId,
|
||||
workspaceId: activeWorkspace._id,
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: <Icon icon="wrench" />,
|
||||
action: () => setIsSettingsModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
name: 'Delete',
|
||||
icon: <Icon icon="trash" />,
|
||||
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: <Icon icon={(plugin.icon as IconName) || 'plug'} />,
|
||||
action: () => handlePluginClick(plugin, activeWorkspace),
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const actionlist = isScratchpadWorkspace ? scratchpadActionList : workspaceActionsList;
|
||||
return (
|
||||
<>
|
||||
<MenuTrigger onOpenChange={isOpen => isOpen && handleDropdownOpen()}>
|
||||
<Button
|
||||
aria-label="Workspace actions"
|
||||
data-testid="workspace-context-dropdown"
|
||||
className="flex h-7 flex-1 items-center justify-center gap-2 truncate rounded-xs px-3 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
>
|
||||
<span className="truncate" title={activeWorkspace.name}>
|
||||
{activeWorkspace.name}
|
||||
</span>
|
||||
<Icon icon="caret-down" />
|
||||
</Button>
|
||||
<Popover className="flex min-w-max flex-col overflow-y-hidden">
|
||||
<Menu
|
||||
aria-label="Create in project actions"
|
||||
selectionMode="single"
|
||||
onAction={key =>
|
||||
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 => (
|
||||
<MenuSection className="flex flex-1 flex-col">
|
||||
<Header className="flex items-center gap-2 py-1 pl-2 text-xs text-(--hl) uppercase">
|
||||
<Icon icon={section.icon} /> <span>{section.name}</span>
|
||||
</Header>
|
||||
<Collection items={section.items}>
|
||||
{item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label={item.name}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
{item.hint && <DropdownHint keyBindings={item.hint} />}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Collection>
|
||||
</MenuSection>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
{isDuplicateModalOpen && (
|
||||
<WorkspaceDuplicateModal onHide={() => setIsDuplicateModalOpen(false)} workspace={activeWorkspace} />
|
||||
)}
|
||||
{isImportModalOpen && (
|
||||
<ImportModal
|
||||
onHide={() => setIsImportModalOpen(false)}
|
||||
from={{ type: 'file' }}
|
||||
projectName={activeProject.name ?? getProductName()}
|
||||
workspaceName={activeWorkspace.name}
|
||||
organizationId={organizationId}
|
||||
defaultProjectId={projectId}
|
||||
defaultWorkspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
{isExportModalOpen && (
|
||||
<ExportRequestsModal workspaceIdToExport={activeWorkspace._id} onClose={() => setIsExportModalOpen(false)} />
|
||||
)}
|
||||
{isSettingsModalOpen && (
|
||||
<WorkspaceSettingsModal
|
||||
workspace={activeWorkspace}
|
||||
mockServer={activeMockServer}
|
||||
project={activeProject}
|
||||
gitFilePath={activeWorkspaceMeta?.gitFilePath}
|
||||
onClose={() => setIsSettingsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isDeleteRemoteWorkspaceModalOpen && (
|
||||
<ModalOverlay
|
||||
isOpen
|
||||
onOpenChange={() => {
|
||||
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"
|
||||
>
|
||||
<Modal
|
||||
onOpenChange={() => {
|
||||
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)"
|
||||
>
|
||||
<Dialog className="outline-hidden">
|
||||
{({ close }) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Heading className="text-2xl">Delete {getWorkspaceLabel(activeWorkspace).singular}</Heading>
|
||||
<Button
|
||||
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
onPress={close}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<deleteWorkspaceFetcher.Form
|
||||
action={href(`/organization/:organizationId/project/:projectId/workspace/delete`, {
|
||||
organizationId,
|
||||
projectId: activeWorkspace.parentId,
|
||||
})}
|
||||
method="POST"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<input type="hidden" name="workspaceId" value={activeWorkspace._id} />
|
||||
<div>
|
||||
<p className="line-clamp-5">
|
||||
This will permanently delete the{' '}
|
||||
<strong className="break-all whitespace-pre-wrap">{activeWorkspace?.name}</strong>{' '}
|
||||
{getWorkspaceLabel(activeWorkspace).singular}
|
||||
</p>
|
||||
{models.project.isRemoteProject(activeProject) && (
|
||||
<RadioGroup name="localOnly" defaultValue="true" className="mb-2 flex flex-col gap-2">
|
||||
<Label className="text-sm text-(--hl)">How do you want to delete it?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Radio
|
||||
value="true"
|
||||
aria-label="Remove Local Copy"
|
||||
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
|
||||
>
|
||||
<div>
|
||||
<Heading className="text-lg font-bold">Remove Local Copy</Heading>
|
||||
<p className="pt-2">The project will still exist on the Cloud.</p>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="false"
|
||||
aria-label="Delete Permanently"
|
||||
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
|
||||
>
|
||||
<div>
|
||||
<Heading className="text-lg font-bold">Delete Permanently</Heading>
|
||||
<p className="pt-2">
|
||||
The project will be deleted everywhere. You cannot undo this action.
|
||||
</p>
|
||||
</div>
|
||||
</Radio>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
{deleteWorkspaceFetcher.data && deleteWorkspaceFetcher.data.error && (
|
||||
<p className="notice error margin-bottom-sm no-margin-top">{deleteWorkspaceFetcher.data.error}</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xs border border-solid border-(--hl-md) bg-(--color-danger) px-3 py-2 text-(--color-font-danger) transition-colors hover:bg-(--color-danger)/90 hover:no-underline"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</deleteWorkspaceFetcher.Form>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -13,9 +13,11 @@ import { Icon } from '../icon';
|
||||
export const KonnectSettingsModal = ({
|
||||
onClose,
|
||||
syncKonnectProjectsAndNotifyRef,
|
||||
onDisconnect,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
syncKonnectProjectsAndNotifyRef: React.MutableRefObject<() => Promise<void>>;
|
||||
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);
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
type Props = ModalProps & {
|
||||
vcs: SyncArchiveVCSLike;
|
||||
};
|
||||
|
||||
interface State {
|
||||
error?: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
export const SyncDeleteModal = ({ vcs, onHide }: Props) => {
|
||||
const modalRef = useRef<ModalHandle>(null);
|
||||
const [state, setState] = useState<State>({
|
||||
error: '',
|
||||
workspaceName: '',
|
||||
});
|
||||
const { activeWorkspace } = useWorkspaceLoaderData()!;
|
||||
|
||||
useEffect(() => {
|
||||
modalRef.current?.show();
|
||||
}, []);
|
||||
const onSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<OverlayContainer>
|
||||
<Modal ref={modalRef} skinny onHide={onHide}>
|
||||
<ModalHeader>Delete {strings.collection.singular}</ModalHeader>
|
||||
<ModalBody className="wide pad-left pad-right text-center" noScroll>
|
||||
{error && <p className="notice error margin-bottom-sm no-margin-top">{error}</p>}
|
||||
<p className="selectable">
|
||||
This will permanently delete the{' '}
|
||||
{<strong style={{ whiteSpace: 'pre-wrap' }}>{activeWorkspace?.name}</strong>}{' '}
|
||||
{strings.collection.singular.toLowerCase()} remotely.
|
||||
</p>
|
||||
<p className="selectable">
|
||||
Please type {<strong style={{ whiteSpace: 'pre-wrap' }}>{activeWorkspace?.name}</strong>} to confirm.
|
||||
</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-control form-control--outlined">
|
||||
<input
|
||||
type="text"
|
||||
onChange={event => setState(state => ({ ...state, workspaceName: event.target.value }))}
|
||||
value={workspaceName}
|
||||
/>
|
||||
<Button bg="danger" disabled={workspaceName !== activeWorkspace?.name}>
|
||||
Delete {strings.collection.singular}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
</OverlayContainer>
|
||||
);
|
||||
};
|
||||
@@ -248,11 +248,25 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
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"
|
||||
/>
|
||||
</TextField>
|
||||
<ProjectTypeSelect
|
||||
storageRules={storageRules}
|
||||
value={storageType}
|
||||
onChange={v => setStorageType(v as 'local' | 'remote' | 'git')}
|
||||
/>
|
||||
{project?.konnectControlPlaneId ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label aria-label="Project Type" className="p-0 text-sm text-(--color-font)">
|
||||
Type
|
||||
</Label>
|
||||
<div className="flex h-7.5 items-center rounded-sm border border-(--hl-sm) px-2 opacity-75">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="laptop" />
|
||||
<span>Synced from Konnect</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectTypeSelect
|
||||
storageRules={storageRules}
|
||||
value={storageType}
|
||||
onChange={v => setStorageType(v as 'local' | 'remote' | 'git')}
|
||||
/>
|
||||
)}
|
||||
<ProjectTypeWarning
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
storageType={storageType}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { Icon } from '../../icon';
|
||||
|
||||
interface KonnectEnvOnboardingProps {
|
||||
triggerElement: HTMLElement | null;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const KonnectEnvOnboarding = ({ triggerElement, onDismiss }: KonnectEnvOnboardingProps) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 w-72 rounded-md border border-solid border-(--hl-md) bg-(--color-bg) p-4 shadow-lg"
|
||||
role="dialog"
|
||||
aria-label="Konnect environment onboarding"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-(--color-font)">
|
||||
Almost ready! Just set your proxy host for each control plane
|
||||
</h3>
|
||||
<button className="shrink-0 text-(--hl) hover:text-(--color-font)" onClick={onDismiss} aria-label="Dismiss">
|
||||
<Icon icon="close" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-(--hl)">
|
||||
Your requests have been automatically set with a{' '}
|
||||
<code className="rounded-xs bg-(--hl-xs) px-1 py-0.5 font-bold text-(--color-font)">proxy_host</code>{' '}
|
||||
environment variable for quick testing against different deployment stages. Enter it here before testing your
|
||||
gateway routes.
|
||||
</p>
|
||||
<button
|
||||
className="mt-3 rounded-md bg-(--color-surprise) px-4 py-1.5 text-sm font-medium text-(--color-font-surprise) transition-colors hover:opacity-90"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Got It
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6665 10.6668H11.3332V12.0001H11.6665C12.1773 11.9998 12.6823 11.8928 13.1493 11.686C13.6163 11.4791 14.0349 11.177 14.3784 10.7989C14.7218 10.4209 14.9825 9.97527 15.1437 9.49061C15.3049 9.00596 15.363 8.49298 15.3144 7.98454C15.2658 7.4761 15.1115 6.98343 14.8615 6.53809C14.6114 6.09275 14.271 5.70457 13.8622 5.39843C13.4533 5.09229 12.985 4.87494 12.4873 4.76031C11.9896 4.64568 11.4734 4.6363 10.9718 4.73277C10.5215 3.57253 9.67965 2.60629 8.59197 2.00141C7.50429 1.39652 6.23932 1.19106 5.01611 1.42062C3.7929 1.65018 2.68848 2.3003 1.89413 3.25839C1.09977 4.21648 0.665496 5.42221 0.666506 6.66677V6.67144H1.99984V6.66677C1.99984 5.6059 2.42127 4.58849 3.17141 3.83834C3.92156 3.0882 4.93897 2.66677 5.99984 2.66677C7.06071 2.66677 8.07812 3.0882 8.82827 3.83834C9.57841 4.58849 9.99984 5.6059 9.99984 6.66677V6.7001C10.2691 6.42538 10.6022 6.2215 10.9693 6.1066C11.3364 5.99171 11.7263 5.96937 12.1041 6.04157C12.482 6.11378 12.8361 6.27828 13.135 6.52045C13.4339 6.76262 13.6683 7.07493 13.8173 7.42959C13.9664 7.78425 14.0254 8.17025 13.9891 8.55323C13.9529 8.93621 13.8225 9.30429 13.6096 9.62471C13.3968 9.94512 13.1079 10.2079 12.7689 10.3897C12.4299 10.5715 12.0512 10.6667 11.6665 10.6668Z" fill="url(#paint0_linear_4311_2318)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.666504 10.6667C0.666504 10.2985 0.964981 10 1.33317 10H10.6665C11.0347 10 11.3332 10.2986 11.3332 10.6668L11.3332 14C11.3332 14.3682 11.0347 14.6667 10.6665 14.6667H1.33317C0.964981 14.6667 0.666504 14.3682 0.666504 14V10.6667ZM5.33317 11.6667H1.99984V13H5.33317V11.6667ZM7.99984 12.3333C7.99984 12.7015 7.70136 13 7.33317 13C6.96498 13 6.66651 12.7015 6.66651 12.3333C6.66651 11.9651 6.96498 11.6667 7.33317 11.6667C7.70136 11.6667 7.99984 11.9651 7.99984 12.3333ZM9.99984 12.3333C9.99984 12.7015 9.70136 13 9.33317 13C8.96498 13 8.66651 12.7015 8.66651 12.3333C8.66651 11.9651 8.96498 11.6667 9.33317 11.6667C9.70136 11.6667 9.99984 11.9651 9.99984 12.3333Z" fill="url(#paint1_linear_4311_2318)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4311_2318" x1="15.3332" y1="1.33333" x2="0.666506" y2="14.6667" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0044F4"/>
|
||||
<stop offset="1" stop-color="#00C8F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_4311_2318" x1="15.3332" y1="1.33333" x2="0.666506" y2="14.6667" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0044F4"/>
|
||||
<stop offset="1" stop-color="#00C8F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg data-testid="kui-icon-svg-runtime-composite-gradient-icon" fill="none" height="100%" role="img" viewBox="0 0 24 24" width="100%" xmlns="http://www.w3.org/2000/svg"><path d="M16 19C15.5333 19 15.0792 18.9542 14.6375 18.8625C14.1958 18.7708 13.7667 18.6417 13.35 18.475C14.3333 17.575 15.1042 16.5708 15.6625 15.4625C16.2208 14.3542 16.5 13.2 16.5 12C16.5 10.8 16.2208 9.64583 15.6625 8.5375C15.1042 7.42917 14.3333 6.425 13.35 5.525C13.7667 5.35833 14.1958 5.22917 14.6375 5.1375C15.0792 5.04583 15.5333 5 16 5C17.95 5 19.6042 5.67917 20.9625 7.0375C22.3208 8.39583 23 10.05 23 12C23 13.95 22.3208 15.6042 20.9625 16.9625C19.6042 18.3208 17.95 19 16 19ZM12 17.75C11.0833 17.1167 10.3542 16.3 9.8125 15.3C9.27083 14.3 9 13.2 9 12C9 10.8 9.27083 9.7 9.8125 8.7C10.3542 7.7 11.0833 6.88333 12 6.25C12.9167 6.88333 13.6458 7.7 14.1875 8.7C14.7292 9.7 15 10.8 15 12C15 13.2 14.7292 14.3 14.1875 15.3C13.6458 16.3 12.9167 17.1167 12 17.75ZM8 19C6.05 19 4.39583 18.3208 3.0375 16.9625C1.67917 15.6042 1 13.95 1 12C1 10.05 1.67917 8.39583 3.0375 7.0375C4.39583 5.67917 6.05 5 8 5C8.46667 5 8.92083 5.04583 9.3625 5.1375C9.80417 5.22917 10.2333 5.35833 10.65 5.525C9.66667 6.425 8.89583 7.42917 8.3375 8.5375C7.77917 9.64583 7.5 10.8 7.5 12C7.5 13.3167 7.77083 14.5375 8.3125 15.6625C8.85417 16.7875 9.6 17.7417 10.55 18.525C10.15 18.675 9.7375 18.7917 9.3125 18.875C8.8875 18.9583 8.45 19 8 19Z" fill="url(#1m1jngw7gm-paint0_linear_2107_23104)"></path>
|
||||
<defs>
|
||||
<linearGradient id="1m1jngw7gm-paint0_linear_2107_23104" x1="23.0032" y1="5.00444" x2="8.05342" y2="24.4286" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6F28FF"></stop>
|
||||
<stop offset="1" stop-color="#5F9AFF"></stop>
|
||||
</linearGradient>
|
||||
</defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -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<KonnectDeploymentType, string> = {
|
||||
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 <Icon icon="laptop" />;
|
||||
}
|
||||
return <img src={icon} alt="" className="h-5 w-5" />;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 4C4.72222 4 4.48611 4.09722 4.29167 4.29167C4.09722 4.48611 4 4.72222 4 5C4 5.27778 4.09722 5.51389 4.29167 5.70833C4.48611 5.90278 4.72222 6 5 6C5.27778 6 5.51389 5.90278 5.70833 5.70833C5.90278 5.51389 6 5.27778 6 5C6 4.72222 5.90278 4.48611 5.70833 4.29167C5.51389 4.09722 5.27778 4 5 4ZM5 10.6667C4.72222 10.6667 4.48611 10.7639 4.29167 10.9583C4.09722 11.1528 4 11.3889 4 11.6667C4 11.9444 4.09722 12.1806 4.29167 12.375C4.48611 12.5694 4.72222 12.6667 5 12.6667C5.27778 12.6667 5.51389 12.5694 5.70833 12.375C5.90278 12.1806 6 11.9444 6 11.6667C6 11.3889 5.90278 11.1528 5.70833 10.9583C5.51389 10.7639 5.27778 10.6667 5 10.6667ZM2.66667 2H13.3333C13.5222 2 13.6806 2.06389 13.8083 2.19167C13.9361 2.31944 14 2.47778 14 2.66667V7.33333C14 7.52222 13.9361 7.68056 13.8083 7.80833C13.6806 7.93611 13.5222 8 13.3333 8H2.66667C2.47778 8 2.31944 7.93611 2.19167 7.80833C2.06389 7.68056 2 7.52222 2 7.33333V2.66667C2 2.47778 2.06389 2.31944 2.19167 2.19167C2.31944 2.06389 2.47778 2 2.66667 2ZM3.33333 3.33333V6.66667H12.6667V3.33333H3.33333ZM2.66667 8.66667H13.3333C13.5222 8.66667 13.6806 8.73056 13.8083 8.85833C13.9361 8.98611 14 9.14444 14 9.33333V14C14 14.1889 13.9361 14.3472 13.8083 14.475C13.6806 14.6028 13.5222 14.6667 13.3333 14.6667H2.66667C2.47778 14.6667 2.31944 14.6028 2.19167 14.475C2.06389 14.3472 2 14.1889 2 14V9.33333C2 9.14444 2.06389 8.98611 2.19167 8.85833C2.31944 8.73056 2.47778 8.66667 2.66667 8.66667ZM3.33333 10V13.3333H12.6667V10H3.33333Z" fill="url(#paint0_linear_4311_2670)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4311_2670" x1="14.0017" y1="2.00402" x2="0.416666" y2="12.6453" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0044F4"/>
|
||||
<stop offset="1" stop-color="#00D6A4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.33317 12.0001V10.6668H11.6665C12.0512 10.6667 12.4299 10.5715 12.7689 10.3897C13.1079 10.2079 13.3968 9.94512 13.6096 9.6247C13.8225 9.30429 13.9529 8.93621 13.9891 8.55323C14.0254 8.17024 13.9664 7.78424 13.8173 7.42959C13.6683 7.07493 13.4339 6.76262 13.135 6.52045C12.8361 6.27828 12.482 6.11378 12.1041 6.04157C11.7263 5.96937 11.3364 5.99171 10.9693 6.1066C10.6022 6.22149 10.2691 6.42538 9.99984 6.7001V6.66677C9.99984 5.6059 9.57841 4.58849 8.82827 3.83834C8.07812 3.0882 7.06071 2.66677 5.99984 2.66677C4.93897 2.66677 3.92156 3.0882 3.17141 3.83834C2.42127 4.58849 1.99984 5.6059 1.99984 6.66677V6.67144H0.666506V6.66677C0.665496 5.42221 1.09977 4.21648 1.89413 3.25839C2.68848 2.3003 3.7929 1.65018 5.01611 1.42062C6.23932 1.19106 7.50429 1.39652 8.59197 2.00141C9.67965 2.60629 10.5215 3.57253 10.9718 4.73277C11.4734 4.6363 11.9896 4.64568 12.4873 4.76031C12.985 4.87494 13.4533 5.09229 13.8622 5.39843C14.271 5.70457 14.6114 6.09275 14.8615 6.53809C15.1115 6.98343 15.2658 7.4761 15.3144 7.98454C15.363 8.49298 15.3049 9.00596 15.1437 9.49061C14.9825 9.97527 14.7218 10.4209 14.3784 10.7989C14.035 11.177 13.6163 11.4791 13.1493 11.686C12.6823 11.8928 12.1773 11.9998 11.6665 12.0001H9.33317ZM3.99984 13.3334H10.6665V14.6668H3.99984V13.3334ZM3.99984 8.0001H9.33317V9.33344H3.99984V8.0001ZM1.33317 10.6668H7.99984V12.0001H1.33317V10.6668Z" fill="url(#paint0_linear_4311_2305)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4311_2305" x1="15.3332" y1="1.33333" x2="0.666406" y2="14.6667" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6F28FF"/>
|
||||
<stop offset="1" stop-color="#FF3C99"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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<number | null>(
|
||||
`${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<SyncResult | null>(null);
|
||||
const [showSyncDetails, setShowSyncDetails] = useState(false);
|
||||
const [copiedReason, setCopiedReason] = useState<string | null>(null);
|
||||
const [onboardingEnvWorkspaceId, setOnboardingEnvWorkspaceId] = useState<string | null>(null);
|
||||
const [envOnboardingNode, setEnvOnboardingNode] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const dismissEnvOnboarding = useCallback(() => {
|
||||
setOnboardingEnvWorkspaceId(null);
|
||||
}, []);
|
||||
|
||||
const skippedRoutesByReason = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) {
|
||||
@@ -938,14 +1014,22 @@ const ProjectNavigationSidebarInner = (
|
||||
<Icon icon="stop-circle" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Sync Konnect"
|
||||
onPress={handleSync}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
|
||||
>
|
||||
<Icon icon="refresh" />
|
||||
Sync
|
||||
</Button>
|
||||
<TooltipTrigger delay={300}>
|
||||
<Button
|
||||
aria-label="Sync Konnect"
|
||||
onPress={handleSync}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
|
||||
>
|
||||
<Icon icon="refresh" />
|
||||
Sync
|
||||
</Button>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-3 py-1.5 text-xs text-(--color-font) shadow-lg select-none"
|
||||
>
|
||||
<LastSyncedLabel lastSyncedAt={lastSyncedAt ?? null} />
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Konnect settings"
|
||||
@@ -1023,6 +1107,10 @@ const ProjectNavigationSidebarInner = (
|
||||
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
// Dismiss onboarding when user navigates to the highlighted environment
|
||||
if (docId === onboardingEnvWorkspaceId) {
|
||||
dismissEnvOnboarding();
|
||||
}
|
||||
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
|
||||
if (
|
||||
routeInfo?.resourceId === docId &&
|
||||
@@ -1081,6 +1169,8 @@ const ProjectNavigationSidebarInner = (
|
||||
});
|
||||
}
|
||||
}}
|
||||
highlighted={item.doc._id === onboardingEnvWorkspaceId}
|
||||
nodeRef={item.doc._id === onboardingEnvWorkspaceId ? setEnvOnboardingNode : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1210,8 +1300,13 @@ const ProjectNavigationSidebarInner = (
|
||||
<KonnectSettingsModal
|
||||
onClose={() => setShowKonnectConfigModal(false)}
|
||||
syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef}
|
||||
onDisconnect={() => setLastSyncedAt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onboardingEnvWorkspaceId && envOnboardingNode && (
|
||||
<KonnectEnvOnboarding triggerElement={envOnboardingNode} onDismiss={dismissEnvOnboarding} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1223,12 +1318,13 @@ export const ProjectNavigationSidebar = forwardRef<ProjectNavigationSidebarHandl
|
||||
export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreateProject: () => void }) => {
|
||||
const { organizationId } = useParams() as { organizationId: string };
|
||||
const isScratchPad = models.organization.isScratchpadOrganizationId(organizationId);
|
||||
const { features } = useOrganizationPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
|
||||
<Tabs>
|
||||
<SideBarTabList
|
||||
konnectSyncEnabled={false}
|
||||
konnectSyncEnabled={features.konnectSync.enabled}
|
||||
isScratchPad={isScratchPad}
|
||||
nonKonnectProjectLength={0}
|
||||
konnectProjectsLength={0}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProjectDropdown, type WorkspaceSortOrder } from '~/ui/components/dropdo
|
||||
|
||||
import { AvatarGroup } from '../../avatar';
|
||||
import { Icon } from '../../icon';
|
||||
import { KonnectProjectIcon } from './konnect-project-icon/konnect-project-icon';
|
||||
import { ACTIVE_BORDER_CLASS, ICON_CLASS, ROW_CLASS, TOGGLE_BTN_CLASS } from './project-navigation-sidebar-utils';
|
||||
import { type ProjectFlatItem } from './types';
|
||||
|
||||
@@ -43,15 +44,19 @@ export const ProjectNode = ({ item, storageRules, onToggle, sortOrder, onSortOrd
|
||||
<Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left transition-colors">
|
||||
<Icon
|
||||
icon={
|
||||
models.project.isRemoteProject(doc)
|
||||
? 'globe-americas'
|
||||
: models.project.isGitProject(doc)
|
||||
? ['fab', 'git-alt']
|
||||
: 'laptop'
|
||||
}
|
||||
/>
|
||||
{doc.konnectControlPlaneId ? (
|
||||
<KonnectProjectIcon konnectDeploymentType={doc.konnectDeploymentType} />
|
||||
) : (
|
||||
<Icon
|
||||
icon={
|
||||
models.project.isRemoteProject(doc)
|
||||
? 'globe-americas'
|
||||
: models.project.isGitProject(doc)
|
||||
? ['fab', 'git-alt']
|
||||
: 'laptop'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-base text-[rgb(var(--color-font-rgb),0.8)]">{projectName}</span>
|
||||
</div>
|
||||
{presence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={presence} />}
|
||||
|
||||
@@ -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<HTMLDivElement> | ((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 (
|
||||
<div
|
||||
className={`${ROW_CLASS} group`}
|
||||
ref={nodeRef}
|
||||
className={`${ROW_CLASS} group ${highlighted ? 'rounded-xs ring-2 ring-(--color-surprise) ring-inset' : ''}`}
|
||||
style={{ paddingLeft: '2em' }}
|
||||
data-testid={`workspace-node-${workspaceName}`}
|
||||
data-project={project.name}
|
||||
|
||||
Reference in New Issue
Block a user