mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-12 02:06:34 +00:00
Integrate Konnect sync with the new sidebar [INS-2358] (#9960)
* feat: breadcrumbs in nav (#9813) * feat: New Navigation SideBar - P1 (#9808) add initial navigation side bar add debug page collection list back support drag sidebar width hide org selector on scratchpad support scratcpad support kconnect * feat: support drag & drop on the new sidebar (#9814) --------- Co-authored-by: Bingbing <[email protected]> * feat: fix scratchpad for the new sidebar (#9815) * feat: mcp ia navigation (#9819) * feat: mcp ia navigation * fix: ca status ui * fix * Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * fix --------- Co-authored-by: Copilot <[email protected]> * feat: Add workspace dropdown for new sidebar (#9821) * support workspace-dropdown in new sidenav * make the drag more easy to touch * feat: auto detect select resource then expand and scroll to (#9835) * feat: list un-synced remote workspaces in sidebar (#9853) * Add nav support for unsynced workspace * support inline change of request name * fix expand issues * feat: fix UI and remove debug settings in design spec and test (#9823) * fix: fix UI style * feat: remove debug settings in spec * fix: remove debug settings in document test * feat: add segment events [INS-2356] * Feat/cloud sync bar (#9866) * fix: fix MCP breadcrumb * feat: sync bar * fix: fix type and unused imports * feat: Add organization actions inside organization selector (#9856) * add organization actions inside organization selector * fix issues * fix type issues * Fix style issue when no projects exist (#9896) * feat: enhance dnd (#9877) * feat: enhance dnd * fix: suggestions from copilot * fix: fix UI styles * feat: Pin/unpin request and collection request sorting (#9903) add basic sort support support toggle header & sidebar add sort support fix issues from comment * fix: tooltip and icon for env picker (#9905) * Feat/ia merge (#9904) * refactor: route fs backed cleanup (#9806) * refactor: shared browser safe helper cleanup (#9810) * refactor: shared browser-safe helper cleanup * style: run eslint autofix * fix: preserve empty url handling * fix: address remaining copilot comments on pr3 * remove loader class * fix: unhandledrejection error (#9774) * fix: resolve sentry promise error (#9786) * fix: resolve sentry promise error * fix: leave fallback when error * fix: improve credential validation handling in GitRepoForm to avoid a loop of re-loading the list of repos and branches (#9820) * add e2e and cli skills (#9818) * add e2e and cli skills * address feedback * address feedback * move to claude * feat: konnect integration proxy url and regex support (#9811) * chore: move konnect sync behind feature flag (#9832) * chore: isolate gRPC proto file preparation behind IPC boundary (#9828) * chore: isolate gRPC proto file preparation behind IPC boundary Move proto temp-file creation out of the renderer by adding a grpc.writeProtoFile IPC handler (main process) and wiring it up in the preload bridge. The renderer's ProtoFilesModal previously called writeProtoFile() directly, pulling node:fs / node:os / node:path into the renderer bundle. It now calls window.main.grpc.writeProtoFile(protoFile._id) instead. Changes: - src/main/ipc/electron.ts: add 'grpc.writeProtoFile' to HandleChannels - src/main/ipc/grpc.ts: export writeProtoFileById helper, add to gRPCBridgeAPI, register ipcMainHandle('grpc.writeProtoFile') - src/entry.preload.ts: wire grpc.writeProtoFile via ipcRenderer.invoke - src/ui/components/modals/proto-files-modal.tsx: remove direct write-proto-file import; use window.main.grpc.writeProtoFile in the directory-import validation loop - config/renderer-node-import-baseline.json: remove 5 stale/resolved baseline entries (proto-directory-loader.tsx x2 already gone; write-proto-file.ts fs/os/path x3 now main-process-only) - src/main/ipc/__tests__/grpc.test.ts: add writeProtoFileById unit tests as contract coverage for the new privileged bridge path Co-authored-by: Copilot <[email protected]> * fix: validate proto syntax in writeProtoFileById IPC handler The directory-import validation loop relied on writeProtoFile for proto content validation, but writeProtoFile only writes the temp file without parsing. Add a protoLoader.load call inside writeProtoFileById so invalid proto syntax throws before the result is returned to the renderer. Also update the test to mock @grpc/proto-loader.load and assert it is called with the correct file path and includeDirs. Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]> * feat: konnect integration strips nunjucks templates on sync (#9831) * fix(Git Sync): auto-resolve non-YAML file conflicts to remote during merge (#9798) * fix: filter conflict paths to include only YAML files * fix: enhance conflict resolution by auto-resolving non-YAML files to theirs * fix: keep buffer raw so that binary files are not corrupted Co-authored-by: Copilot <[email protected]> * fix: enhance merge conflict handling by introducing auto-resolved conflicts for non-YAML files * fix: add test for handling merge conflicts, ensuring only YAML conflicts are returned * fix: prevent HEAD update during auto-resolve of merge conflicts * fix: enhance merge conflict resolution by auto-completing merges when all conflicts are non-YAML --------- Co-authored-by: Copilot <[email protected]> * feat: konnect integration expressions support (#9830) * Show more specific error when creating mock route fails (#9841) * fix: insomnia-ai-plugin uses securedPath (INS-2244) (#9748) * feat: add custom npm registry mirror setting for plugin installation (#9837) * feat: default user-agent for cURL imports [INS-2416] (#9838) * feat: default user-agent for cURL imports * respect disableAppVersionUserAgent setting * fix: view transition error - [INS-2316] (#9792) * fix: view transition error * fix * change default behavior when delete cloud sync workspaces (#9844) * feat: integrate v3 user endpoints (#9785) * feat: integrate v3 user endpoints * feat: use public sdk for insomnia-api * chore: applied PoLP to workflows (#9840) * chore: resolve GHA warning annotations and reduce CI time [INS-2312] (#9839) * fix: resolves INS-2366 (#9852) * fix: resolves INS-2366 dependency issues * Refactor:use electron store for oauth session (#9851) * move oauth session to electron storage * create electron storage bridge * use electronStorage bridge for managing oauth window handles * fix build * move key to constants * tolerate changing userData folder path * Update packages/insomnia/src/main/ipc/electron-storage.ts Co-authored-by: Copilot <[email protected]> * init store * fix singleton class * feedback * feedback * Update packages/insomnia/src/main/electron-storage.ts Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> * chore: decouple releases (#9842) * INS-2145 Decouple releases * fix security error * fix * check version * refactor: auth header to main (#9834) * remove deprecated baseUrl * add failing test * fix AI playwright runs * move getAuthHeader to main * address feedback about dynamic import * move oauth 1 + 2 flow to main * handle bad cookie * handle bad apikey * fix imports * block main process imports * extract plugins * fix vite config * console log * move init store * Fix OAuth imports after rebase Co-authored-by: Copilot <[email protected]> * clean up * Revert config changes Co-authored-by: Copilot <[email protected]> * clean up hawk * use bridge * update node require * remove this * define process type * remove 14 * ignore reports folder * fix e2e tests * address feedback * remove unused * tidy constants * feat: add getOAuth2Token IPC bridge Co-authored-by: Copilot <[email protected]> * fix tests --------- Co-authored-by: Copilot <[email protected]> * fix: Support pin and unpin websocket and socketio requests (#9865) * support pin websocket and socketio requests * feat(Git Sync): Add support for canonical repository output (#9789) * initial support for canonical repo output (#9739) * Feat/git repo output sync queue (#9790) * feat: implement SyncQueue for serial async task processing * refactor: enhance repo file watcher for improved sync and error handling - Replace NeDB client with a unified disk client for all file operations. - Introduce a serial queue to manage sync tasks and prevent race conditions. - Implement content-hash deduplication to avoid unnecessary file imports. - Add problem tracking for YAML files with conflicts or parse errors. - Streamline watcher start/stop logic and improve notification handling. - Ensure immediate DB→FS flush before git operations to maintain consistency. - Enhance import logic to handle workspace deletions and renames effectively. * refactor: simplify projectRoutableFSClient by removing unused parameters and consolidating logic * feat: add git.db-synced event listener for revalidation in Root component * feat: add button to open local repository folder in ProjectSettingsForm * refactor: remove unused GitProjectNeDBClient * refactor: update imports to use services for workspace and workspaceMeta * refactor: update models usage to services in git repo migration and project settings form * refactor: streamline file watcher initialization and import process * feat: ensure immediate processing of pending debounced imports in RepoFileWatcher * refactor: improve file rename handling in RepoFileWatcher to prevent data loss * feat: enhance RepoFileWatcher to track last written hash and sync mtime for improved file management * refactor: remove unused parameters from upsertDocs in RepoFileWatcher for cleaner code * fix revalidator (#9826) * fix: handle detached HEAD during rebase in getCurrentBranch method (#9843) * fix: handle detached HEAD during rebase in getCurrentBranch method * fix: add return type to getCurrentBranch method * fix: refresh ui after sync (#9848) * fix: (git cli)skip flush problematic files (#9846) * fix: skip flush problematic files * fix * feat: (git cli)ux for invalide status (#9836) * feat: ux for invalide status * update ux * fix * fix * add tab warning * del log * feat(Git Sync): Handle non-origin remotes (#9833) * feat(git): detect non-origin branch tracking and guard sync operations - Add getBranchTrackingRemote(), getRemoteUrl(), getBranchRemoteInfo() to GitVCS - Add getBranchRemoteInfo IPC endpoint with BranchRemoteInfo interface - Add assertBranchOnOrigin() guard to push, pull, fetch, commitAndPush - canPushLoader returns { canPush: false } for non-origin branches - Add unit tests for remote detection methods * feat(git): add support for non-origin branch tracking and display warnings in UI * Show local git repo path [INS-2315] (#9858) * Update the style of local git folder path in project setting modal * Add Git CLI tip in commit changes modal * Repo Migration flow [INS-2256] (#9824) * initial support for canonical repo output (#9739) * feat: enhance git repository migration with concurrency guard and symlink handling * feat: enhance git repository migration with config sanitization and file overwrite handling * feat: implement repo migration version tracking and improve migration idempotency * feat: add runAllGitRepoMigrations function and migration view for Git projects Co-authored-by: Copilot <[email protected]> * fix: reset initial migration status to 'default' in MigrationView component * refactor: simplify MigrationView component and update navigation logic * refactor: remove legacy directory structure migration from loadGitRepository function * feat: enhance runAllGitRepoMigrations to return logs and improve error handling in MigrationView * feat: update runAllGitRepoMigrations to return detailed logs and failed projects; enhance MigrationView to handle migration results * feat: optimize runAllGitRepoMigrations by batch-fetching git repositories and improving project filtering * feat: introduce CURRENT_MIGRATION_VERSION constant for migration tracking and update references in git-repo-migration and router * feat: handle failed projects in runAllGitRepoMigrations by converting them to local projects Co-authored-by: Copilot <[email protected]> * feat: integrate CURRENT_MIGRATION_VERSION for migration tracking and update router logic to handle migration screen visibility * feat: reorder import statements in ProjectSettingsForm for consistency * feat: update MigrationStatus type and related logic for better error handling * feat: enhance migration logging with detailed error stack and include CURRENT_MIGRATION_VERSION in logs * feat: simplify migration logging messages for clarity and consistency * feat: improve migration check logic to prioritize version stamp over disk layout * feat: add tests for migrateRepoStructureIfNeeded function to ensure migration logic correctness * feat: update migration logic to re-run when old git/ directory exists, ensuring correct migration handling * test: update migration tests to ensure directory existence checks are accurate * refactor: remove redundant useEffect for localStorage in Component * feat: enhance path validation in runAllGitRepoMigrations to prevent path traversal vulnerabilities * feat: enhance path handling in migration functions to prevent directory traversal vulnerabilities * feat: enhance directory traversal protection in moveDirectoryContents function --------- Co-authored-by: James Gatz <[email protected]> Co-authored-by: Copilot <[email protected]> * fix: Delete old folders (#9867) * refactor: remove unused migration version handling from localStorage * fix: update directory removal logic to handle non-empty directories --------- Co-authored-by: Curry Yang <[email protected]> Co-authored-by: yaoweiprc <[email protected]> Co-authored-by: Pavlos Koutoglou <[email protected]> Co-authored-by: Copilot <[email protected]> * refactor: move sync code to main (#9827) * move sync code to main * improve sync tests * update plan * test: reset cloud sync smoke state Co-authored-by: Copilot <[email protected]> * guard against bad test env * fix skill * remove new test * udpate plan * with proxy * checkpoint * move files * autofix * update plan * make all sync bridge async * fix window imports * refactor: move main-only sync helpers Co-authored-by: Copilot <[email protected]> * fix lint * move chunkArray tests * smaller interfaces * move store under vcs * move cloud-sync to main * create a second vcs for pull operations * added a invoke wrapper to remove error prefixes * rebase error --------- Co-authored-by: Copilot <[email protected]> * chore: update insomnia-plugin-ai (#9862) * fix: bump node-libcurl and add ipv6 tests (#9869) * feat: revamping pre/post scripting sandbox (#9794) * feat: revamping pre/post scripting sandbox * feat: added UI setting to enable/disable specific checks * fix false positives * revert * fix: user can not resolve conflict in app (#9872) * fix: conflict ux * fix * fix * Git server for smoke test [INS-2258] (#9816) * Git server for smoke test * Try to solve flaky test * feat: remove unused Git hook samples and add Credentials tab functionality - Deleted various sample Git hook scripts from the git-server fixture, including post-update, pre-applypatch, pre-commit, pre-merge-commit, pre-push, pre-rebase, pre-receive, prepare-commit-msg, push-to-checkout, sendemail-validate, and update hooks. - Introduced a new PreferencesCredentialsTab class to manage Git credentials within the Insomnia Preferences. - Updated the PreferencesPage to include the new Credentials tab for Git credentials management. - Enhanced the ProjectPage with a method to create a Git Sync project, including branch creation and switching. - Added comprehensive tests for Git Sync functionality, including creating branches, committing changes, and merging branches. - Updated UI components to support new features, including data-testid attributes for better testability. Co-authored-by: Copilot <[email protected]> * feat: update path import and add Git sync tests * revert package.json * Update package.json * feat: add new dependencies for Git HTTP mock server and related utilities * refactor: remove commented-out code in addAccessTokenGitCredential function * fix: update export tests to use toHaveLength for file count assertions --------- Co-authored-by: Copilot <[email protected]> * feat: import deep-link login experience [INS-2416] (#9860) * refactor: replace node:url with URL in cert and proxy match (#9515) * refactor: import to main (#9809) * squash * re add comments * fix process fork * update base line 18 left * revert * check cert url without node * fix handlerId * exclude url matches cert host from scope * fix rebase * Fix style issue that file list in the middle of commit modal is collapsed [INS-2315] (#9875) * Fix style issue that file list in the middle of commit modal is collapsed * fix: update links to Git Sync documentation in staging modal and project settings form * Chore: playwright dx v2 (#9876) * Update E2E test for git sync [INS-2258] (#9878) * Add more test cases for git sync * tmp * Update package.json * feat: update migration image and urls (#9868) * feat: update migration image and path for improved clarity * feat: update error message and support links in migration view * feat(Git Sync): Downgrade -> Upgrade path (#9882) * feat: add mechanism to flush newer DB workspaces to disk during downgrade * feat: implement effective Git repository ID handling for project connections * feat: enhance Git repository ID handling for improved project queries and updates * Chore: refine e2e docs by agent for agent (#9881) * improve agent docs * added error context note * chore: Security update for dependencies and github workflows (#9884) * chore: resolves INS-2457, INS-2458, INS-2459, and INS-2460. * feat: include app version in window title [INS-2465] (#9888) * feat(Git Sync): improve git migration onboarding UX and local file system access [INS-2462] (#9890) * feat(Migration): enhance migration summary with total projects count and improve UI feedback * fix(Migration): clarify update instructions and improve user messaging * style(ManualCommitForm): adjust text sizes for improved readability * style(StagingModal): adjust layout and spacing for improved UI consistency * fix(ManualCommitForm): update clipboard text to include 'cd' command for easier navigation * fix(ProjectSettingsForm): update repository path copy functionality and add option to open in file system * fix(ManualCommitForm): enhance file system interaction with tooltips for better user guidance * fix(ProjectSettingsForm): add tooltip for 'Open in file system' button to enhance user guidance * fix(GitProjectSyncDropdown): add 'Open folder' action to sync dropdown for easier access to repository path * fix(Component): display relative path of current issue in modal for better context * fix(git-service): count only successfully migrated projects in totalProjects * fix(project-settings-form): platform-aware shell quoting for cd command * fix(git-project-staging-modal): platform-aware shell quoting for cd command * fix(project-settings-form): update aria-label to reflect cd command clipboard content * fix(git-project-staging-modal): update aria-label to reflect shell command clipboard content * fix(ManualCommitForm): replace tooltip with dialog for enhanced information display * fix(MigrationView): update migrated count calculation to reflect total projects --------- Co-authored-by: James Gatz <[email protected]> * chore: normalize konnect api responses (#9895) * feat(Git Sync): enhance migration view with best practices note and UI improvements (#9900) * chore: comment out smctl credentials save in workflow (#9898) the command is no longer executed while keeping it in place for future reference. * feat: migrate model imports, base types, org model and helpers (#9802) * fix * fix vcsinstance * fix type issues --------- Co-authored-by: Jack Kavanagh <[email protected]> Co-authored-by: James Gatz <[email protected]> Co-authored-by: Shelby <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: yaoweiprc <[email protected]> Co-authored-by: kwburns-kong <[email protected]> Co-authored-by: jeremyjpj0916 <[email protected]> Co-authored-by: Ryan Willis <[email protected]> Co-authored-by: Kent Wang <[email protected]> Co-authored-by: Alison Sabuwala <[email protected]> Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> Co-authored-by: Jay Wu <[email protected]> Co-authored-by: Pavlos Koutoglou <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Fares Osman <[email protected]> Co-authored-by: Bingbing <[email protected]> Co-authored-by: Vivek Thuravupala <[email protected]> * fix: fix dnd for empty nodes (#9909) * fix: fix dnd for empty nodes * remove console log Co-authored-by: Copilot Autofix powered by AI <[email protected]> --------- Co-authored-by: Copilot Autofix powered by AI <[email protected]> * initial fix for critical paths (#9913) * 1.enhance the test component * add test attributes * fix basic test case * fix smoke test p1 * fix smoke test p2 * fix e2e test failure p3 * fix smoke test p4 * fix remaining e2e failures * feat: secondary sidebar&git cli error modal adaptation (#9916) * feat: secondary sidebar&git cli error modal * fix * Fix: Document design page do not show collection list (#9930) * fix no collection list showed in design page document tab * use common constant for the wrapper id * fix: move parent position control into modal component --------- Co-authored-by: Curry Yang <[email protected]> * Test: Fix all smoke test failures due to new navigation sidebar (#9921) * enhance the test component * add test attributes * fix smoke test * Fix failed git-sync E2E tests. * fix type issues * fix all e2e failure * update screenshot * fix issues from pr comment * fix toggle issues * fix grpc and smoke test issue * fix slot issue * tmp * tmp * Integrate Konnect sync with the new sidebar * Apply style changes from https://github.com/Kong/insomnia/pull/9963 * fix: Using react-aria tab and extract common component for sidebar tab (#9967) * extract common component and refine tabs * fix issues * streamlines button classes and spacing tweaks (#9969) * Try to fix error in Check renderer Node import baseline * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <[email protected]> * Cancel sync err message * Fix review issues * minor ux fix (#9971) * Disable disconnect button while validating PAT --------- Co-authored-by: xdm <[email protected]> Co-authored-by: Kent Wang <[email protected]> Co-authored-by: Bingbing <[email protected]> Co-authored-by: Curry Yang <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Jack Kavanagh <[email protected]> Co-authored-by: James Gatz <[email protected]> Co-authored-by: Shelby <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: kwburns-kong <[email protected]> Co-authored-by: jeremyjpj0916 <[email protected]> Co-authored-by: Ryan Willis <[email protected]> Co-authored-by: Alison Sabuwala <[email protected]> Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> Co-authored-by: Jay Wu <[email protected]> Co-authored-by: Pavlos Koutoglou <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Fares Osman <[email protected]> Co-authored-by: Vivek Thuravupala <[email protected]> Co-authored-by: Curry Yang <[email protected]> Co-authored-by: Missy Turco <[email protected]>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
xdm
Kent Wang
Bingbing
Curry Yang
Jack Kavanagh
James Gatz
Shelby
Copilot
kwburns-kong
jeremyjpj0916
Ryan Willis
Alison Sabuwala
aikido-pr-checks[bot]
Jay Wu
Pavlos Koutoglou
Copilot
Fares Osman
Vivek Thuravupala
Curry Yang
Missy Turco
parent
7cd8854f24
commit
637ae78d43
Vendored
+1
@@ -30,6 +30,7 @@
|
||||
"Dismissable",
|
||||
"getinsomnia",
|
||||
"inso",
|
||||
"Konnect",
|
||||
"libcurl",
|
||||
"mockbin",
|
||||
"Revalidator",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"verify-bundle-plugins": "esr --cache ./scripts/verify-bundle-plugins.ts",
|
||||
"install-x64-native-dependencies": "esr --cache ./scripts/install-x64-native-dependencies.ts",
|
||||
"build": "react-router build && esr --cache ./scripts/build.ts --noErrorTruncation",
|
||||
"analyze:renderer-node-imports": "cross-env INSOMNIA_NODE_IMPORT_REPORT=1 react-router build",
|
||||
"analyze:renderer-node-imports": "cross-env NODE_OPTIONS=--max-old-space-size=8192 INSOMNIA_NODE_IMPORT_REPORT=1 react-router build",
|
||||
"check:renderer-node-imports": "npm run analyze:renderer-node-imports && esr --cache ./scripts/check-renderer-node-imports.ts",
|
||||
"update:renderer-node-import-baseline": "npm run analyze:renderer-node-imports && esr --cache ./scripts/check-renderer-node-imports.ts --write-baseline",
|
||||
"build:react-router": "react-router build",
|
||||
|
||||
@@ -113,7 +113,6 @@ export const getMockServiceBinURL = (mockServer: MockServer, path: string) => {
|
||||
|
||||
export const getAIServiceURL = () => env.INSOMNIA_AI_URL || 'https://ai-helper.insomnia.rest';
|
||||
export const getKonnectApiBaseURL = () => env.KONNECT_API_URL || 'https://global.api.konghq.com';
|
||||
export const isKonnectSyncEnabled = () => !!env.KONNECT_SYNC_ENABLED;
|
||||
|
||||
// App website
|
||||
export const getAppWebsiteBaseURL = () => env.INSOMNIA_APP_WEBSITE_URL || 'https://app.insomnia.rest';
|
||||
|
||||
@@ -67,7 +67,7 @@ interface ServiceSyncContext {
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
function zeroCounts(): SyncCounts {
|
||||
export function zeroCounts(): SyncCounts {
|
||||
return { total: 0, created: 0, updated: 0, deleted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
@@ -114,14 +114,17 @@ interface ExistingRequestData {
|
||||
async function loadExistingRequestData(workspaceId: string): Promise<ExistingRequestData> {
|
||||
// Include requests up to 2 levels deep (workspace → route folders → path×protocol sub-folders).
|
||||
const topFolders = await db.find<RequestGroup>(models.requestGroup.type, { parentId: workspaceId });
|
||||
const subFolders = topFolders.length > 0
|
||||
? await db.find<RequestGroup>(models.requestGroup.type, { parentId: { $in: topFolders.map(f => f._id) } })
|
||||
: [];
|
||||
const subFolders =
|
||||
topFolders.length > 0
|
||||
? await db.find<RequestGroup>(models.requestGroup.type, { parentId: { $in: topFolders.map(f => f._id) } })
|
||||
: [];
|
||||
const allFolders = [...topFolders, ...subFolders];
|
||||
const parentIds = [workspaceId, ...allFolders.map(f => f._id)];
|
||||
const query = { parentId: { $in: parentIds }, konnectRouteKey: { $ne: null } };
|
||||
const httpDocs = (await db.find<Request>(models.request.type, query)).filter(r => r.konnectRouteKey != null);
|
||||
const wsDocs = (await db.find<WebSocketRequest>(models.webSocketRequest.type, query)).filter(r => r.konnectRouteKey != null);
|
||||
const wsDocs = (await db.find<WebSocketRequest>(models.webSocketRequest.type, query)).filter(
|
||||
r => r.konnectRouteKey != null,
|
||||
);
|
||||
const grpcDocs = (await db.find<GrpcRequest>(models.grpcRequest.type, query)).filter(r => r.konnectRouteKey != null);
|
||||
return {
|
||||
maps: {
|
||||
@@ -144,7 +147,10 @@ async function syncGrpcRoute(
|
||||
const grpcProtocols = route.protocols.filter(p => p === 'grpc' || p === 'grpcs') as ('grpc' | 'grpcs')[];
|
||||
const multiProtocol = grpcProtocols.length > 1;
|
||||
const paths = route.paths ?? [null];
|
||||
const metadata = Object.entries(route.headers ?? {}).map(([n, values]: [string, string[]]) => ({ name: n.toLowerCase(), value: values[0] }));
|
||||
const metadata = Object.entries(route.headers ?? {}).map(([n, values]: [string, string[]]) => ({
|
||||
name: n.toLowerCase(),
|
||||
value: values[0],
|
||||
}));
|
||||
|
||||
const routeFolderId = await upsertRouteFolder(workspaceId, routeDisplayName(route), route.id);
|
||||
|
||||
@@ -172,12 +178,31 @@ async function syncGrpcRoute(
|
||||
const konnectManagedHeaderNames = metadata.map(h => h.name);
|
||||
if (existing) {
|
||||
const merged = mergeHeaders(existing.metadata ?? [], metadata, existing.konnectManagedHeaderNames ?? []);
|
||||
if (existing.url !== url || existing.name !== name || existing.protoMethodName !== protoMethodName || konnectHeadersChanged(existing.metadata ?? [], metadata, existing.konnectManagedHeaderNames ?? [])) {
|
||||
await insoservices.grpcRequest.update(existing, { url, name, protoMethodName, metadata: merged, konnectManagedHeaderNames });
|
||||
if (
|
||||
existing.url !== url ||
|
||||
existing.name !== name ||
|
||||
existing.protoMethodName !== protoMethodName ||
|
||||
konnectHeadersChanged(existing.metadata ?? [], metadata, existing.konnectManagedHeaderNames ?? [])
|
||||
) {
|
||||
await insoservices.grpcRequest.update(existing, {
|
||||
url,
|
||||
name,
|
||||
protoMethodName,
|
||||
metadata: merged,
|
||||
konnectManagedHeaderNames,
|
||||
});
|
||||
routeCounts.updated++;
|
||||
}
|
||||
} else {
|
||||
await insoservices.grpcRequest.create({ parentId, url, name, protoMethodName, metadata, konnectRouteKey: key, konnectManagedHeaderNames });
|
||||
await insoservices.grpcRequest.create({
|
||||
parentId,
|
||||
url,
|
||||
name,
|
||||
protoMethodName,
|
||||
metadata,
|
||||
konnectRouteKey: key,
|
||||
konnectManagedHeaderNames,
|
||||
});
|
||||
routeCounts.created++;
|
||||
}
|
||||
}
|
||||
@@ -222,12 +247,31 @@ async function syncWsRoute(
|
||||
if (existing) {
|
||||
const merged = mergeHeaders(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []);
|
||||
const mergedPathParams = mergePathParameters(existing.pathParameters ?? [], pathParameters);
|
||||
if (existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) || pathParametersChanged(existing.pathParameters ?? [], pathParameters)) {
|
||||
await insoservices.webSocketRequest.update(existing, { url, name, headers: merged, pathParameters: mergedPathParams, konnectManagedHeaderNames });
|
||||
if (
|
||||
existing.url !== url ||
|
||||
existing.name !== name ||
|
||||
konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) ||
|
||||
pathParametersChanged(existing.pathParameters ?? [], pathParameters)
|
||||
) {
|
||||
await insoservices.webSocketRequest.update(existing, {
|
||||
url,
|
||||
name,
|
||||
headers: merged,
|
||||
pathParameters: mergedPathParams,
|
||||
konnectManagedHeaderNames,
|
||||
});
|
||||
routeCounts.updated++;
|
||||
}
|
||||
} else {
|
||||
await insoservices.webSocketRequest.create({ parentId, url, name, headers, pathParameters, konnectRouteKey: key, konnectManagedHeaderNames });
|
||||
await insoservices.webSocketRequest.create({
|
||||
parentId,
|
||||
url,
|
||||
name,
|
||||
headers,
|
||||
pathParameters,
|
||||
konnectRouteKey: key,
|
||||
konnectManagedHeaderNames,
|
||||
});
|
||||
routeCounts.created++;
|
||||
}
|
||||
}
|
||||
@@ -275,12 +319,34 @@ async function syncHttpRoute(
|
||||
if (existing) {
|
||||
const merged = mergeHeaders(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []);
|
||||
const mergedPathParams = mergePathParameters(existing.pathParameters ?? [], pathParameters);
|
||||
if (existing.method !== method || existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) || pathParametersChanged(existing.pathParameters ?? [], pathParameters)) {
|
||||
await insoservices.request.update(existing, { method, url, name, headers: merged, pathParameters: mergedPathParams, konnectManagedHeaderNames });
|
||||
if (
|
||||
existing.method !== method ||
|
||||
existing.url !== url ||
|
||||
existing.name !== name ||
|
||||
konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) ||
|
||||
pathParametersChanged(existing.pathParameters ?? [], pathParameters)
|
||||
) {
|
||||
await insoservices.request.update(existing, {
|
||||
method,
|
||||
url,
|
||||
name,
|
||||
headers: merged,
|
||||
pathParameters: mergedPathParams,
|
||||
konnectManagedHeaderNames,
|
||||
});
|
||||
routeCounts.updated++;
|
||||
}
|
||||
} else {
|
||||
await insoservices.request.create({ parentId, method, url, name, headers, pathParameters, konnectRouteKey: key, konnectManagedHeaderNames });
|
||||
await insoservices.request.create({
|
||||
parentId,
|
||||
method,
|
||||
url,
|
||||
name,
|
||||
headers,
|
||||
pathParameters,
|
||||
konnectRouteKey: key,
|
||||
konnectManagedHeaderNames,
|
||||
});
|
||||
routeCounts.created++;
|
||||
}
|
||||
}
|
||||
@@ -300,13 +366,22 @@ async function deleteStaleRequests(
|
||||
): Promise<void> {
|
||||
// Delete konnect-managed requests whose key no longer matches an incoming route
|
||||
const stale: (() => Promise<void>)[] = [
|
||||
...[...existingData.maps.http.values()].filter(r => !incomingKeys.has(r.konnectRouteKey!)).map(r => () => insoservices.request.remove(r)),
|
||||
...[...existingData.maps.ws.values()].filter(r => !incomingKeys.has(r.konnectRouteKey!)).map(r => () => insoservices.webSocketRequest.remove(r)),
|
||||
...[...existingData.maps.grpc.values()].filter(r => !incomingKeys.has(r.konnectRouteKey!)).map(r => () => insoservices.grpcRequest.remove(r)),
|
||||
...[...existingData.maps.http.values()]
|
||||
.filter(r => !incomingKeys.has(r.konnectRouteKey!))
|
||||
.map(r => () => insoservices.request.remove(r)),
|
||||
...[...existingData.maps.ws.values()]
|
||||
.filter(r => !incomingKeys.has(r.konnectRouteKey!))
|
||||
.map(r => () => insoservices.webSocketRequest.remove(r)),
|
||||
...[...existingData.maps.grpc.values()]
|
||||
.filter(r => !incomingKeys.has(r.konnectRouteKey!))
|
||||
.map(r => () => insoservices.grpcRequest.remove(r)),
|
||||
];
|
||||
|
||||
// Delete user-added requests (no konnectRouteKey) that live in the workspace or its folders.
|
||||
const noKeyQuery = { parentId: { $in: existingData.parentIds }, $or: [{ konnectRouteKey: null }, { konnectRouteKey: { $exists: false } }] };
|
||||
const noKeyQuery = {
|
||||
parentId: { $in: existingData.parentIds },
|
||||
$or: [{ konnectRouteKey: null }, { konnectRouteKey: { $exists: false } }],
|
||||
};
|
||||
const userHttp = await db.find<Request>(models.request.type, noKeyQuery);
|
||||
const userWs = await db.find<WebSocketRequest>(models.webSocketRequest.type, noKeyQuery);
|
||||
const userGrpc = await db.find<GrpcRequest>(models.grpcRequest.type, noKeyQuery);
|
||||
@@ -326,12 +401,16 @@ async function deleteStaleRequests(
|
||||
const folderIds = existingData.folders.map(f => f._id);
|
||||
const foldersWithChildren = new Set<string>([
|
||||
...(await db.find<Request>(models.request.type, { parentId: { $in: folderIds } })).map(r => r.parentId),
|
||||
...(await db.find<WebSocketRequest>(models.webSocketRequest.type, { parentId: { $in: folderIds } })).map(r => r.parentId),
|
||||
...(await db.find<WebSocketRequest>(models.webSocketRequest.type, { parentId: { $in: folderIds } })).map(
|
||||
r => r.parentId,
|
||||
),
|
||||
...(await db.find<GrpcRequest>(models.grpcRequest.type, { parentId: { $in: folderIds } })).map(r => r.parentId),
|
||||
...(await db.find<RequestGroup>(models.requestGroup.type, { parentId: { $in: folderIds } })).map(f => f.parentId),
|
||||
]);
|
||||
for (const folder of existingData.folders) {
|
||||
if (!folder.konnectRouteId) { continue; }
|
||||
if (!folder.konnectRouteId) {
|
||||
continue;
|
||||
}
|
||||
if (!incomingRouteIds.has(folder.konnectRouteId) || !foldersWithChildren.has(folder._id)) {
|
||||
await insoservices.requestGroup.remove(folder);
|
||||
}
|
||||
@@ -359,7 +438,12 @@ async function syncServiceWorkspace(
|
||||
workspace = existingWorkspace;
|
||||
}
|
||||
} else {
|
||||
workspace = await insoservices.workspace.create({ parentId: project._id, name: serviceName, scope: 'collection', konnectServiceId: service.id });
|
||||
workspace = await insoservices.workspace.create({
|
||||
parentId: project._id,
|
||||
name: serviceName,
|
||||
scope: 'collection',
|
||||
konnectServiceId: service.id,
|
||||
});
|
||||
counts.services.created++;
|
||||
}
|
||||
counts.services.total++;
|
||||
@@ -371,7 +455,9 @@ async function syncServiceWorkspace(
|
||||
}
|
||||
await insoservices.cookieJar.getOrCreateForParentId(workspace._id);
|
||||
|
||||
const incomingRoutes = (await fetchRoutesForService(pat, controlPlane.id, service.id, region, signal)).map(sanitizeRoute);
|
||||
const incomingRoutes = (await fetchRoutesForService(pat, controlPlane.id, service.id, region, signal)).map(
|
||||
sanitizeRoute,
|
||||
);
|
||||
const existingData = await loadExistingRequestData(workspace._id);
|
||||
const incomingKeys = new Set<string>();
|
||||
const incomingRouteIds = new Set<string>();
|
||||
@@ -396,7 +482,11 @@ async function syncServiceWorkspace(
|
||||
|
||||
if (isL4) {
|
||||
counts.routes.skipped++;
|
||||
skippedRoutes.push({ routeName, reason: `Unsupported protocol: ${effectiveRoute.protocols.join(', ')}`, serviceName });
|
||||
skippedRoutes.push({
|
||||
routeName,
|
||||
reason: `Unsupported protocol: ${effectiveRoute.protocols.join(', ')}`,
|
||||
serviceName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -416,9 +506,14 @@ async function syncServiceWorkspace(
|
||||
// Host header only applies to HTTP/WS — gRPC uses :authority which Insomnia derives from the URL
|
||||
const headers = [
|
||||
...(effectiveRoute.hosts?.[0] ? [{ name: 'host', value: effectiveRoute.hosts[0] }] : []),
|
||||
...Object.entries(effectiveRoute.headers ?? {}).map(([name, values]) => ({ name: name.toLowerCase(), value: values[0] })),
|
||||
...Object.entries(effectiveRoute.headers ?? {}).map(([name, values]) => ({
|
||||
name: name.toLowerCase(),
|
||||
value: values[0],
|
||||
})),
|
||||
];
|
||||
await (isWs ? syncWsRoute(effectiveRoute, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys) : syncHttpRoute(effectiveRoute, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys));
|
||||
await (isWs
|
||||
? syncWsRoute(effectiveRoute, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys)
|
||||
: syncHttpRoute(effectiveRoute, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,13 +527,14 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project:
|
||||
parentId: project._id,
|
||||
scope: 'environment',
|
||||
});
|
||||
const envWorkspace = existingEnvWorkspaces.length > 0
|
||||
? existingEnvWorkspaces[0]
|
||||
: await insoservices.workspace.create({
|
||||
parentId: project._id,
|
||||
name: `${controlPlane.name} Environment`,
|
||||
scope: 'environment',
|
||||
});
|
||||
const envWorkspace =
|
||||
existingEnvWorkspaces.length > 0
|
||||
? existingEnvWorkspaces[0]
|
||||
: await insoservices.workspace.create({
|
||||
parentId: project._id,
|
||||
name: `${controlPlane.name} Environment`,
|
||||
scope: 'environment',
|
||||
});
|
||||
|
||||
const projectEnv = await insoservices.environment.getOrCreateForParentId(envWorkspace._id);
|
||||
const existingKvPairs = projectEnv.kvPairData ?? [];
|
||||
@@ -446,7 +542,13 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project:
|
||||
const proxyDefaults = deriveProxyVarDefaults(controlPlane.proxy_urls);
|
||||
const newKvPairs = [...KONNECT_PROXY_VAR_NAMES]
|
||||
.filter(name => !existingByName.has(name))
|
||||
.map(name => ({ id: `env_${name}`, name, value: proxyDefaults[name] ?? '', type: EnvironmentKvPairDataType.STRING, enabled: true }));
|
||||
.map(name => ({
|
||||
id: `env_${name}`,
|
||||
name,
|
||||
value: proxyDefaults[name] ?? '',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
// For existing vars that are still empty, fill in from proxy_urls if available
|
||||
const updatedExisting = existingKvPairs.map(kv => {
|
||||
@@ -497,7 +599,10 @@ async function syncControlPlane(
|
||||
let project = existingProjectsByKonnectId.get(controlPlane.id);
|
||||
if (project) {
|
||||
if (project.name !== controlPlane.name || project.konnectClusterType !== controlPlane.config.cluster_type) {
|
||||
project = await insoservices.project.update(project, { name: controlPlane.name, konnectClusterType: controlPlane.config.cluster_type });
|
||||
project = await insoservices.project.update(project, {
|
||||
name: controlPlane.name,
|
||||
konnectClusterType: controlPlane.config.cluster_type,
|
||||
});
|
||||
acc.controlPlaneCounts.updated++;
|
||||
}
|
||||
} else {
|
||||
@@ -517,10 +622,12 @@ async function syncControlPlane(
|
||||
const services = await fetchAllServices(pat, controlPlane.id, region, signal);
|
||||
|
||||
// Load existing Konnect workspaces for this project once, keyed by service id
|
||||
const existingWorkspaces = (await db.find<Workspace>(models.workspace.type, {
|
||||
parentId: project._id,
|
||||
konnectServiceId: { $ne: null },
|
||||
})).filter(w => w.konnectServiceId != null);
|
||||
const existingWorkspaces = (
|
||||
await db.find<Workspace>(models.workspace.type, {
|
||||
parentId: project._id,
|
||||
konnectServiceId: { $ne: null },
|
||||
})
|
||||
).filter(w => w.konnectServiceId != null);
|
||||
const existingWorkspaceByServiceId = new Map(existingWorkspaces.map(w => [w.konnectServiceId!, w]));
|
||||
const incomingServiceIds = new Set(services.map(s => s.id));
|
||||
|
||||
@@ -531,12 +638,20 @@ async function syncControlPlane(
|
||||
for (let i = 0; i < services.length; i += CONCURRENCY) {
|
||||
signal?.throwIfAborted();
|
||||
const batch = services.slice(i, i + CONCURRENCY);
|
||||
const batchResults = await Promise.all(batch.map(async service => {
|
||||
const localCounts = { services: zeroCounts(), routes: zeroCounts() };
|
||||
const localSkipped: SkippedRoute[] = [];
|
||||
await syncServiceWorkspace(ctx, service, existingWorkspaceByServiceId.get(service.id), localCounts, localSkipped);
|
||||
return { counts: localCounts, skipped: localSkipped };
|
||||
}));
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async service => {
|
||||
const localCounts = { services: zeroCounts(), routes: zeroCounts() };
|
||||
const localSkipped: SkippedRoute[] = [];
|
||||
await syncServiceWorkspace(
|
||||
ctx,
|
||||
service,
|
||||
existingWorkspaceByServiceId.get(service.id),
|
||||
localCounts,
|
||||
localSkipped,
|
||||
);
|
||||
return { counts: localCounts, skipped: localSkipped };
|
||||
}),
|
||||
);
|
||||
for (const { counts, skipped } of batchResults) {
|
||||
mergeCounts(acc.serviceCounts, counts.services);
|
||||
mergeCounts(acc.routeCounts, counts.routes);
|
||||
@@ -567,10 +682,12 @@ export async function syncKonnect({ pat, organizationId, signal, onProgress }: S
|
||||
|
||||
try {
|
||||
// Load all existing Konnect projects up front to avoid per Control Plane queries
|
||||
const existingProjects = (await db.find<Project>(models.project.type, {
|
||||
parentId: organizationId,
|
||||
konnectControlPlaneId: { $ne: null },
|
||||
})).filter(p => p.konnectControlPlaneId != null);
|
||||
const existingProjects = (
|
||||
await db.find<Project>(models.project.type, {
|
||||
parentId: organizationId,
|
||||
konnectControlPlaneId: { $ne: null },
|
||||
})
|
||||
).filter(p => p.konnectControlPlaneId != null);
|
||||
const existingProjectsByKonnectId = new Map(existingProjects.map(p => [p.konnectControlPlaneId!, p]));
|
||||
const incomingControlPlaneIds = new Set<string>();
|
||||
const syncCtx: SyncContext = { pat, organizationId, existingProjectsByKonnectId, signal, onProgress };
|
||||
@@ -604,6 +721,14 @@ export async function syncKonnect({ pat, organizationId, signal, onProgress }: S
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
return { success: false, controlPlanes: acc.controlPlaneCounts, services: acc.serviceCounts, routes: acc.routeCounts, skippedRoutes: acc.skippedRoutes, durationMs, error: errorMessage };
|
||||
return {
|
||||
success: false,
|
||||
controlPlanes: acc.controlPlaneCounts,
|
||||
services: acc.serviceCounts,
|
||||
routes: acc.routeCounts,
|
||||
skippedRoutes: acc.skippedRoutes,
|
||||
durationMs,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
|
||||
|
||||
import { database } from '~/common/database';
|
||||
import { services } from '~/insomnia-data';
|
||||
import { validatePat } from '~/konnect/api';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { AnalyticsEvent } from '~/ui/analytics';
|
||||
|
||||
import { useSettingsPatcher } from '../../hooks/use-request';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
export const KonnectSettingsModal = ({
|
||||
onClose,
|
||||
syncKonnectProjectsAndNotifyRef,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
syncKonnectProjectsAndNotifyRef: React.MutableRefObject<() => Promise<void>>;
|
||||
}) => {
|
||||
const { settings } = useRootLoaderData()!;
|
||||
const patchSettings = useSettingsPatcher();
|
||||
|
||||
const [pat, setPat] = useState('');
|
||||
const [isPatVisible, setIsPatVisible] = useState(false);
|
||||
// 'idle' | 'validating' | 'valid' | 'invalid'
|
||||
const [status, setStatus] = useState<'idle' | 'validating' | 'valid' | 'invalid'>('idle');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
// Controls whether the disconnect confirmation screen is shown
|
||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
|
||||
// Validate the given PAT against the Konnect API and update status/error state accordingly.
|
||||
const validateAndSetStatus = async (trimmed: string) => {
|
||||
setStatus('validating');
|
||||
setValidationError(null);
|
||||
const result = await validatePat(trimmed);
|
||||
setStatus(result.valid ? 'valid' : 'invalid');
|
||||
if (!result.valid) {
|
||||
setValidationError(result.error ?? 'Invalid PAT. Check your input and try again.');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// On mount: if a PAT is already stored, load it from secure storage and validate it.
|
||||
useEffect(() => {
|
||||
if (settings.hasKonnectPat) {
|
||||
window.main.secretStorage.getSecret('konnectPat').then(secret => {
|
||||
if (secret) {
|
||||
setPat(secret);
|
||||
validateAndSetStatus(secret);
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Validate the PAT, persist it to secure storage, and trigger an initial sync.
|
||||
const handleConnect = async () => {
|
||||
const trimmed = pat.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const result = await validateAndSetStatus(trimmed);
|
||||
if (result.valid) {
|
||||
await window.main.secretStorage.setSecret('konnectPat', trimmed);
|
||||
patchSettings({ hasKonnectPat: true });
|
||||
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated });
|
||||
syncKonnectProjectsAndNotifyRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
// Delete all Konnect-synced projects from the local DB, remove the stored PAT, and close the modal.
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
const allProjects = await services.project.list();
|
||||
const konnectProjects = allProjects.filter(p => p.konnectControlPlaneId != null);
|
||||
const bufferId = await database.bufferChangesIndefinitely();
|
||||
try {
|
||||
for (const project of konnectProjects) {
|
||||
await services.project.remove(project);
|
||||
}
|
||||
} finally {
|
||||
await database.flushChanges(bufferId);
|
||||
}
|
||||
await window.main.secretStorage.deleteSecret('konnectPat');
|
||||
patchSettings({ hasKonnectPat: false });
|
||||
onClose();
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// A connected state means the PAT is saved and has not been found invalid.
|
||||
const isConnected = settings.hasKonnectPat && status !== 'invalid';
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen
|
||||
isDismissable
|
||||
onOpenChange={isOpen => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
|
||||
>
|
||||
<Modal className="flex w-full max-w-3xl flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)">
|
||||
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-hidden">
|
||||
{({ close }) => (
|
||||
<div className="flex h-full flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Heading slot="title" className="text-lg font-bold">
|
||||
{showDisconnectConfirm ? 'Disconnect Kong Konnect?' : 'Kong Konnect settings'}
|
||||
</Heading>
|
||||
<Button
|
||||
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-sm text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset"
|
||||
onPress={close}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showDisconnectConfirm ? (
|
||||
<>
|
||||
<p className="text-sm text-(--hl)">
|
||||
Disconnecting will remove your Personal Access Token and delete related project data. This action
|
||||
cannot be undone. Are you sure?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
className="rounded-xs border border-solid border-(--hl-sm) px-4 py-2 text-sm text-(--color-font) hover:bg-(--hl-xs)"
|
||||
onPress={() => setShowDisconnectConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-xs bg-(--color-danger) px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
isDisabled={isDisconnecting}
|
||||
onPress={handleDisconnect}
|
||||
>
|
||||
{isDisconnecting ? <Icon icon="spinner" className="animate-spin" /> : 'Disconnect & delete data'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-semibold" htmlFor="konnect-modal-pat">
|
||||
Personal Access Token
|
||||
</label>
|
||||
<p className="text-sm text-(--hl)">
|
||||
Enter a Personal Access Token (PAT) to sync your Konnect control planes into Insomnia projects.
|
||||
</p>
|
||||
<button
|
||||
className="w-fit text-sm text-(--color-font) underline hover:opacity-80"
|
||||
onClick={() => window.main.openInBrowser('https://cloud.konghq.com/global/account/tokens')}
|
||||
>
|
||||
Generate new PAT <Icon icon="arrow-up-right-from-square" className="text-xs" />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
id="konnect-modal-pat"
|
||||
type={isPatVisible ? 'text' : 'password'}
|
||||
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1.5 pr-8 text-(--color-font) focus:border-(--hl-lg) focus:outline-hidden"
|
||||
placeholder={
|
||||
isConnected
|
||||
? 'Enter new PAT to replace existing'
|
||||
: 'e.g. kpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
}
|
||||
value={pat}
|
||||
onChange={e => {
|
||||
setPat(e.target.value);
|
||||
if (status !== 'idle') {
|
||||
setStatus('idle');
|
||||
setValidationError(null);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
aria-label={isPatVisible ? 'Hide PAT' : 'Show PAT'}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 text-sm text-(--hl) hover:text-(--color-font)"
|
||||
onPress={() => setIsPatVisible(v => !v)}
|
||||
>
|
||||
<Icon icon={isPatVisible ? 'eye-slash' : 'eye'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status === 'invalid' && (
|
||||
<p className="text-sm text-(--color-danger)">
|
||||
{validationError ?? 'Invalid PAT. Check your input and try again.'}
|
||||
</p>
|
||||
)}
|
||||
{(status === 'valid' || (isConnected && status === 'idle')) && (
|
||||
<p className="text-sm text-(--color-success)">Connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="rounded-xs border border-solid border-(--hl-sm) px-3 py-1.5 text-sm text-(--color-font) hover:bg-(--hl-xs) disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
isDisabled={!pat.trim() || status === 'validating'}
|
||||
onPress={handleConnect}
|
||||
>
|
||||
{status === 'validating' ? <Icon icon="spinner" className="animate-spin" /> : 'Connect & Sync'}
|
||||
</Button>
|
||||
{isConnected && (
|
||||
<Button
|
||||
className="rounded-xs border border-solid border-(--hl-sm) px-3 py-1.5 text-sm font-semibold text-(--color-font) hover:bg-(--hl-xs)"
|
||||
onPress={() => setShowDisconnectConfirm(true)}
|
||||
isDisabled={status === 'validating'}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,12 @@
|
||||
import { getOrganizationFeatures } from 'insomnia-api';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { AI_PLUGIN_NAME, isKonnectSyncEnabled } from '~/common/constants';
|
||||
import { models } from '~/insomnia-data';
|
||||
import { AI_PLUGIN_NAME } from '~/common/constants';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { AnalyticsEvent } from '~/ui/analytics';
|
||||
import { AISettings } from '~/ui/components/settings/ai-settings';
|
||||
import { CredentialsSettings } from '~/ui/components/settings/credentials';
|
||||
import { KonnectSettings } from '~/ui/components/settings/konnect-settings';
|
||||
import { ScriptingSettings } from '~/ui/components/settings/scripting-settings';
|
||||
|
||||
import { getAppVersion, getProductName } from '../../../common/constants';
|
||||
@@ -32,7 +29,16 @@ export interface SettingsModalHandle {
|
||||
show: (options?: { tab?: SettingsModalTabKey }) => void;
|
||||
}
|
||||
|
||||
type SettingsModalTabKey = 'data' | 'keyboard' | 'themes' | 'plugins' | 'general' | 'proxy' | 'credentials' | 'ai' | 'scripting' | 'konnect';
|
||||
type SettingsModalTabKey =
|
||||
| 'data'
|
||||
| 'keyboard'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'general'
|
||||
| 'proxy'
|
||||
| 'credentials'
|
||||
| 'ai'
|
||||
| 'scripting';
|
||||
|
||||
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
|
||||
const [defaultTabKey, setDefaultTabKey] = useState('general');
|
||||
@@ -42,29 +48,12 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
|
||||
const { organizationId } = useParams() as { organizationId?: string };
|
||||
|
||||
const [shouldShowAiSettingsTab, setShouldShowAiSettingsTab] = useState(false);
|
||||
const [shouldShowKonnectTab, setShouldShowKonnectTab] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkFeatures = async () => {
|
||||
const plugins = await pluginsBridge.getBundlePlugins();
|
||||
const aiPlugin = plugins.find(p => p.name === AI_PLUGIN_NAME);
|
||||
setShouldShowAiSettingsTab(!!aiPlugin && !!userSession.id);
|
||||
|
||||
if (
|
||||
isKonnectSyncEnabled() &&
|
||||
userSession.id &&
|
||||
organizationId &&
|
||||
!models.organization.isScratchpadOrganizationId(organizationId)
|
||||
) {
|
||||
try {
|
||||
const res = await getOrganizationFeatures({ organizationId, sessionId: userSession.id });
|
||||
setShouldShowKonnectTab(res?.features?.konnectSync?.enabled ?? false);
|
||||
} catch {
|
||||
setShouldShowKonnectTab(false);
|
||||
}
|
||||
} else {
|
||||
setShouldShowKonnectTab(false);
|
||||
}
|
||||
};
|
||||
checkFeatures();
|
||||
}, [userSession.id, organizationId]);
|
||||
@@ -173,14 +162,6 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
|
||||
AI Settings
|
||||
</Tab>
|
||||
)}
|
||||
{shouldShowKonnectTab && (
|
||||
<Tab
|
||||
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
|
||||
id="konnect"
|
||||
>
|
||||
Konnect
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanel className="h-full w-full overflow-y-auto" id="general">
|
||||
<General />
|
||||
@@ -244,11 +225,6 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
|
||||
<AISettings />
|
||||
</TabPanel>
|
||||
)}
|
||||
{shouldShowKonnectTab && (
|
||||
<TabPanel className="h-full w-full overflow-y-auto" id="konnect">
|
||||
<KonnectSettings />
|
||||
</TabPanel>
|
||||
)}
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import type { StorageRules } from 'insomnia-api';
|
||||
import type React from 'react';
|
||||
import { Button, GridList, GridListItem, Input, SearchField } from 'react-aria-components';
|
||||
import { useNavigate } from 'react-router';
|
||||
import * as reactUse from 'react-use';
|
||||
|
||||
import type { GitRepository, Project } from '~/insomnia-data';
|
||||
import { models } from '~/insomnia-data';
|
||||
import type { SyncResult } from '~/konnect/sync';
|
||||
import { AnalyticsEvent } from '~/ui/analytics';
|
||||
|
||||
import { useKonnectSync } from '../../hooks/use-konnect-sync';
|
||||
import { AvatarGroup } from '../avatar';
|
||||
import { ProjectDropdown } from '../dropdowns/project-dropdown';
|
||||
import { Icon } from '../icon';
|
||||
import { showModal } from '../modals';
|
||||
import { AlertModal } from '../modals/alert-modal';
|
||||
import { AskModal } from '../modals/ask-modal';
|
||||
|
||||
export type ProjectWithPresence = Project & {
|
||||
gitRepository?: GitRepository;
|
||||
presence: {
|
||||
key: string;
|
||||
alt: string;
|
||||
src: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
interface ProjectListSidebarProps {
|
||||
organizationId: string;
|
||||
activeProjectId?: string;
|
||||
projects: ProjectWithPresence[];
|
||||
storageRules: StorageRules;
|
||||
onCreateProject: () => void;
|
||||
konnectSyncEnabled: boolean;
|
||||
}
|
||||
|
||||
const TAB_CLASS_ACTIVE = 'border-b-2 border-solid border-b-(--color-surprise) px-3 py-1 text-xs uppercase text-(--color-font)';
|
||||
const TAB_CLASS_INACTIVE = 'px-3 py-1 text-xs uppercase text-(--hl) hover:bg-(--hl-xs)';
|
||||
|
||||
const ROW_CLASS = 'relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden px-4 text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font)';
|
||||
|
||||
const ACTION_BUTTON_CLASS = 'flex h-full items-center gap-1 rounded-xs px-2 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)';
|
||||
|
||||
const ProjectGridList = ({ label, items, activeProjectId, organizationId, children }: {
|
||||
label: string;
|
||||
items: ProjectWithPresence[];
|
||||
activeProjectId?: string;
|
||||
organizationId: string;
|
||||
children: (item: ProjectWithPresence) => React.ReactNode;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<GridList
|
||||
aria-label={label}
|
||||
items={items}
|
||||
className="flex-1 overflow-y-auto py-(--padding-sm) data-empty:py-0"
|
||||
disallowEmptySelection
|
||||
selectedKeys={activeProjectId ? [activeProjectId] : ['']}
|
||||
selectionMode="single"
|
||||
onSelectionChange={keys => {
|
||||
if (keys !== 'all') {
|
||||
const [value] = keys.values();
|
||||
navigate({ pathname: `/organization/${organizationId}/project/${value}` });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item => (
|
||||
<GridListItem key={item._id} id={item._id} textValue={item.name} className="group outline-hidden select-none">
|
||||
<div className={ROW_CLASS}>
|
||||
<span className="absolute top-0 left-0 h-full w-[2px] bg-transparent transition-colors group-aria-selected:bg-(--color-surprise)" />
|
||||
{children(item)}
|
||||
</div>
|
||||
</GridListItem>
|
||||
)}
|
||||
</GridList>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterSearchField = ({ label, value, onChange, isDisabled }: { label: string; value: string; onChange: (value: string) => void; isDisabled?: boolean }) => (
|
||||
<SearchField aria-label={label} className="group relative flex-1" value={value} onChange={onChange} isDisabled={isDisabled}>
|
||||
<Input
|
||||
placeholder="Filter"
|
||||
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"
|
||||
/>
|
||||
<div className="absolute top-0 right-0 flex h-full items-center px-2">
|
||||
<Button className="flex aspect-square w-5 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all group-data-empty:hidden hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)">
|
||||
<Icon icon="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</SearchField>
|
||||
);
|
||||
|
||||
const filterByName = (items: ProjectWithPresence[], query: string | undefined) =>
|
||||
query ? items.filter(p => p.name?.toLowerCase().includes(query.toLowerCase())) : items;
|
||||
|
||||
function showSkippedRoutesModal(result: SyncResult | null) {
|
||||
if (!result?.success || !result.skippedRoutes.length) { return; }
|
||||
const byService = new Map<string, string[]>();
|
||||
for (const { serviceName, routeName, reason } of result.skippedRoutes) {
|
||||
const list = byService.get(serviceName) ?? [];
|
||||
list.push(`${routeName} — ${reason}`);
|
||||
byService.set(serviceName, list);
|
||||
}
|
||||
showModal(AlertModal, {
|
||||
title: 'Skipped Routes',
|
||||
message: (
|
||||
<div>
|
||||
<p>{result.skippedRoutes.length} route(s) were skipped because they cannot be represented in Insomnia:</p>
|
||||
{[...byService.entries()].map(([service, routes]) => (
|
||||
<div key={service} style={{ margin: '8px 0' }}>
|
||||
<strong>{service}</strong>
|
||||
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
|
||||
{routes.map(r => <li key={r}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export const ProjectListSidebar = ({
|
||||
organizationId,
|
||||
activeProjectId,
|
||||
projects,
|
||||
storageRules,
|
||||
onCreateProject,
|
||||
konnectSyncEnabled,
|
||||
}: ProjectListSidebarProps) => {
|
||||
|
||||
const [storedTab, setActiveTab] = reactUse.useLocalStorage<'projects' | 'konnect'>(
|
||||
`${organizationId}:sidebar-tab`,
|
||||
'projects',
|
||||
);
|
||||
const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects');
|
||||
|
||||
const [projectListFilter, setProjectListFilter] = reactUse.useLocalStorage(
|
||||
`${organizationId}:project-list-filter`,
|
||||
'',
|
||||
);
|
||||
|
||||
const [konnectFilter, setKonnectFilter] = reactUse.useLocalStorage(
|
||||
`${organizationId}:konnect-filter`,
|
||||
'',
|
||||
);
|
||||
|
||||
const { syncing, progress, error: syncError, startSync, cancelSync } = useKonnectSync();
|
||||
|
||||
const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId);
|
||||
const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null);
|
||||
|
||||
const filteredProjects = filterByName(nonKonnectProjects, projectListFilter);
|
||||
const filteredKonnectProjects = filterByName(konnectProjects, konnectFilter);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!konnectSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runAndNotify = async () => {
|
||||
const result = await startSync(organizationId);
|
||||
showSkippedRoutesModal(result);
|
||||
};
|
||||
|
||||
const isResync = konnectProjects.length > 0;
|
||||
if (isResync) {
|
||||
showModal(AskModal, {
|
||||
title: 'Re-sync Konnect',
|
||||
message: (
|
||||
<div>
|
||||
<p>Re-syncing will make the following changes:</p>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li><strong>Reset</strong> — request method, URL, name, and Konnect-managed headers</li>
|
||||
<li><strong>Delete</strong> — requests added manually or no longer in Konnect</li>
|
||||
<li><strong>Preserve</strong> — body, auth, query params, scripts, description, and user-added headers</li>
|
||||
</ul>
|
||||
<p>This cannot be undone. Continue?</p>
|
||||
</div>
|
||||
),
|
||||
yesText: 'Re-sync',
|
||||
noText: 'Cancel',
|
||||
color: 'warning',
|
||||
onDone: async (confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
await runAndNotify();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await runAndNotify();
|
||||
}
|
||||
};
|
||||
|
||||
const tabBar = (
|
||||
<div className="flex shrink-0 border-b border-solid border-b-(--hl-md)">
|
||||
<button className={activeTab === 'projects' ? TAB_CLASS_ACTIVE : TAB_CLASS_INACTIVE} onClick={() => setActiveTab('projects')}>
|
||||
Projects ({nonKonnectProjects.length})
|
||||
</button>
|
||||
{konnectSyncEnabled && (
|
||||
<button className={activeTab === 'konnect' ? TAB_CLASS_ACTIVE : TAB_CLASS_INACTIVE} onClick={() => setActiveTab('konnect')}>
|
||||
Konnect ({konnectProjects.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (activeTab === 'konnect' && konnectSyncEnabled) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{tabBar}
|
||||
<div className="flex justify-between gap-1 p-(--padding-sm)">
|
||||
<FilterSearchField label="Konnect filter" value={konnectFilter || ''} onChange={setKonnectFilter} />
|
||||
{syncing ? (
|
||||
<Button
|
||||
aria-label="Cancel sync"
|
||||
onPress={() => cancelSync()}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
<Icon icon="stop-circle" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Sync Konnect"
|
||||
onPress={handleSync}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
Sync
|
||||
<Icon icon="refresh" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncing && (
|
||||
<p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>
|
||||
)}
|
||||
{syncError && (
|
||||
<p className="px-4 pb-1 text-xs text-(--color-danger)">{syncError}</p>
|
||||
)}
|
||||
|
||||
<ProjectGridList label="Konnect projects" items={filteredKonnectProjects} activeProjectId={activeProjectId} organizationId={organizationId}>
|
||||
{item => {
|
||||
return (
|
||||
<>
|
||||
<Icon icon='server' />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</ProjectGridList>
|
||||
|
||||
{konnectProjects.length === 0 && !syncing && (
|
||||
<p className="px-4 py-2 text-xs text-(--hl) italic">
|
||||
No Konnect projects yet. Click sync to import your control planes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{tabBar}
|
||||
<div className="flex justify-between gap-1 p-(--padding-sm)">
|
||||
<FilterSearchField
|
||||
label="Projects filter"
|
||||
value={projectListFilter || ''}
|
||||
isDisabled={nonKonnectProjects.length === 0}
|
||||
onChange={value => {
|
||||
setProjectListFilter(value);
|
||||
if (value.trim() !== '') {
|
||||
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.filterCreatedProjects });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Create new Project"
|
||||
onPress={onCreateProject}
|
||||
className="flex aspect-square h-full 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)"
|
||||
>
|
||||
<Icon icon="plus-circle" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ProjectGridList label="Projects" items={filteredProjects} activeProjectId={activeProjectId} organizationId={organizationId}>
|
||||
{item => (
|
||||
<>
|
||||
<Icon icon={models.project.isRemoteProject(item) ? 'globe-americas' : models.project.isGitProject(item) ? ['fab', 'git-alt'] : 'laptop'} />
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="flex-1" />
|
||||
{item.presence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={item.presence} />}
|
||||
{item._id !== models.project.SCRATCHPAD_PROJECT_ID && (
|
||||
<ProjectDropdown organizationId={organizationId} project={item} storageRules={storageRules} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ProjectGridList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
import { validatePat } from '~/konnect/api';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { AnalyticsEvent } from '~/ui/analytics';
|
||||
|
||||
import { useSettingsPatcher } from '../../hooks/use-request';
|
||||
|
||||
export const KonnectSettings = () => {
|
||||
const { settings } = useRootLoaderData()!;
|
||||
const patchSettings = useSettingsPatcher();
|
||||
|
||||
const [pat, setPat] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'validating' | 'valid' | 'invalid'>('idle');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleValidate = async () => {
|
||||
const trimmed = pat.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setStatus('validating');
|
||||
setValidationError(null);
|
||||
const result = await validatePat(trimmed);
|
||||
setStatus(result.valid ? 'valid' : 'invalid');
|
||||
if (result.valid) {
|
||||
await window.main.secretStorage.setSecret('konnectPat', trimmed);
|
||||
patchSettings({ hasKonnectPat: true });
|
||||
setPat('');
|
||||
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated });
|
||||
} else {
|
||||
setValidationError(result.error ?? 'PAT is invalid or could not connect to Konnect.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
await window.main.secretStorage.deleteSecret('konnectPat');
|
||||
patchSettings({ hasKonnectPat: false });
|
||||
setPat('');
|
||||
setStatus('idle');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="sticky top-0 left-0 z-10 bg-(--color-bg) pt-2 pb-2 text-lg font-bold">Kong Konnect</h2>
|
||||
<p className="mb-4 text-sm text-(--hl)">
|
||||
Enter a Personal Access Token (PAT) to sync your Konnect control planes into Insomnia projects.
|
||||
Generate one at{' '}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://cloud.konghq.com/global/account/tokens"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
window.main.openInBrowser('https://cloud.konghq.com/global/account/tokens');
|
||||
}}
|
||||
>
|
||||
https://cloud.konghq.com/global/account/tokens
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor="konnect-pat">
|
||||
Personal Access Token
|
||||
</label>
|
||||
{settings.hasKonnectPat ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-(--hl)">kpat_••••••••</span>
|
||||
<span className="text-xs text-(--color-success)">Saved</span>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
id="konnect-pat"
|
||||
type="password"
|
||||
className="rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-(--color-font) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
|
||||
placeholder={settings.hasKonnectPat ? 'Enter new PAT to replace existing' : 'kpat_...'}
|
||||
value={pat}
|
||||
onChange={e => {
|
||||
setPat(e.target.value);
|
||||
setStatus('idle');
|
||||
setValidationError(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{status === 'valid' && <p className="text-xs text-(--color-success)">PAT is valid and saved.</p>}
|
||||
{status === 'invalid' && <p className="text-xs text-(--color-danger)">{validationError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="rounded-xs bg-(--color-surprise) px-3 py-1 text-sm text-white hover:opacity-90 disabled:opacity-50"
|
||||
isDisabled={!pat.trim() || status === 'validating'}
|
||||
onPress={handleValidate}
|
||||
>
|
||||
{status === 'validating' ? 'Validating...' : 'Validate & Save'}
|
||||
</Button>
|
||||
{settings.hasKonnectPat && (
|
||||
<Button
|
||||
className="rounded-xs border border-solid border-(--hl-sm) px-3 py-1 text-sm text-(--color-font) hover:bg-(--hl-xs)"
|
||||
onPress={handleClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
+30
@@ -0,0 +1,30 @@
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
import bgUrl from './bg.png';
|
||||
|
||||
interface KonnectSyncIntroProps {
|
||||
onConfigure: () => void;
|
||||
}
|
||||
|
||||
export const KonnectSyncIntro = ({ onConfigure }: KonnectSyncIntroProps) => {
|
||||
return (
|
||||
<div className="m-2 flex flex-col overflow-hidden rounded-md bg-black text-center text-white">
|
||||
<img src={bgUrl} alt="" className="w-full object-cover" />
|
||||
<div className="flex flex-col items-center gap-5 px-4 pb-10">
|
||||
<span className="rounded-sm border border-[#b5f021] px-2 py-0.5 text-xs font-semibold text-[#b5f021]">NEW</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base leading-snug font-bold">Auto-sync your gateway service routes</h3>
|
||||
<p className="text-sm text-[#aaa]">
|
||||
Get right into testing your gateway configuration in Insomnia with the new Konnect platform integration.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onPress={onConfigure}
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) px-4 py-2 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, Input, SearchField } from 'react-aria-components';
|
||||
|
||||
import { Icon } from '../../icon';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
isScratchPad: boolean;
|
||||
tabs: { name: 'projects' | 'konnect'; label: ReactNode }[];
|
||||
activeTab: 'projects' | 'konnect';
|
||||
onTabChange: (tab: 'projects' | 'konnect') => void;
|
||||
filterValue: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
isFilterDisabled: boolean;
|
||||
actionButton: ReactNode;
|
||||
}
|
||||
|
||||
// Sidebar header component including project & konnect tabs, filter input, and action button.
|
||||
export const SidebarHeader = ({
|
||||
isScratchPad,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
isFilterDisabled,
|
||||
actionButton,
|
||||
}: SidebarHeaderProps) => (
|
||||
<>
|
||||
<div className="flex shrink-0 border-b border-solid border-b-(--hl-md)">
|
||||
{!isScratchPad &&
|
||||
tabs.map(({ name, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={`border-b-2 border-solid px-4 py-2 text-xs ${activeTab === name ? 'border-(--color-surprise) text-(--color-font)' : 'border-b-transparent text-(--hl) hover:bg-(--hl-xs)'}`}
|
||||
data-testid={`sidebar-tab-${name}`}
|
||||
onClick={() => onTabChange(name)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 p-(--padding-sm)">
|
||||
<SearchField
|
||||
aria-label="Projects filter"
|
||||
className="group relative flex-1"
|
||||
value={filterValue}
|
||||
isDisabled={isFilterDisabled}
|
||||
onChange={onFilterChange}
|
||||
>
|
||||
<Input
|
||||
placeholder="Filter"
|
||||
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"
|
||||
/>
|
||||
<div className="absolute top-0 right-0 flex h-full items-center px-2">
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="flex aspect-square w-5 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all group-data-empty:hidden hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
>
|
||||
<Icon icon="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</SearchField>
|
||||
{actionButton}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
+443
-250
@@ -1,10 +1,11 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { StorageRules } from 'insomnia-api';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, GridList, GridListItem } from 'react-aria-components';
|
||||
import { Button, GridList, GridListItem, Input, SearchField, Tab, TabList, Tabs } from 'react-aria-components';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import * as reactUse from 'react-use';
|
||||
|
||||
import { Button as BasicButton } from '~/basic-components/button';
|
||||
import type { SortOrder } from '~/common/constants';
|
||||
import { fuzzyMatchAll } from '~/common/misc';
|
||||
import {
|
||||
@@ -20,19 +21,20 @@ import { useProjectLoaderData } from '~/routes/organization.$organizationId.proj
|
||||
import { AnalyticsEvent } from '~/ui/analytics';
|
||||
import { KongLogo } from '~/ui/components/kong-logo';
|
||||
import { showModal } from '~/ui/components/modals';
|
||||
import { AlertModal } from '~/ui/components/modals/alert-modal';
|
||||
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 { 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';
|
||||
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 insomniaLogo from '~/ui/images/insomnia-logo.svg';
|
||||
import { isPrimaryClickModifier } from '~/ui/utils';
|
||||
|
||||
import { Icon } from '../../icon';
|
||||
import { SidebarHeader } from './project-navigation-sidebar-header';
|
||||
import {
|
||||
type AllRequestsAndMetaInWorkspace,
|
||||
filterCollection,
|
||||
@@ -48,45 +50,97 @@ import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop';
|
||||
import { WorkspaceNode } from './workspace-node';
|
||||
|
||||
interface ProjectNavigationSidebarProps {
|
||||
storageRules: StorageRules;
|
||||
activeNodeId?: string;
|
||||
konnectSyncEnabled: boolean;
|
||||
storageRules: StorageRules;
|
||||
onCreateProject: () => void;
|
||||
}
|
||||
|
||||
function showSkippedRoutesModal(result: SyncResult | null) {
|
||||
if (!result?.success || !result.skippedRoutes.length) {
|
||||
return;
|
||||
}
|
||||
const byService = new Map<string, string[]>();
|
||||
for (const { serviceName, routeName, reason } of result.skippedRoutes) {
|
||||
const list = byService.get(serviceName) ?? [];
|
||||
list.push(`${routeName} — ${reason}`);
|
||||
byService.set(serviceName, list);
|
||||
}
|
||||
showModal(AlertModal, {
|
||||
title: 'Skipped Routes',
|
||||
message: (
|
||||
<div>
|
||||
<p>{result.skippedRoutes.length} route(s) were skipped because they cannot be represented in Insomnia:</p>
|
||||
{[...byService.entries()].map(([service, routes]) => (
|
||||
<div key={service} style={{ margin: '8px 0' }}>
|
||||
<strong>{service}</strong>
|
||||
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
|
||||
{routes.map(r => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
const SidebarSearchField = ({
|
||||
value,
|
||||
isDisabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
isDisabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<SearchField
|
||||
aria-label="Projects filter"
|
||||
className="group relative flex-1"
|
||||
value={value}
|
||||
isDisabled={isDisabled}
|
||||
onChange={onChange}
|
||||
>
|
||||
<Input
|
||||
placeholder="Filter"
|
||||
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"
|
||||
/>
|
||||
<div className="absolute top-0 right-0 flex h-full items-center px-2">
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="flex aspect-square w-5 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all group-data-empty:hidden hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
>
|
||||
<Icon icon="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</SearchField>
|
||||
);
|
||||
|
||||
const SideBarTabList = ({
|
||||
konnectSyncEnabled,
|
||||
isScratchPad,
|
||||
nonKonnectProjectLength,
|
||||
konnectProjectsLength,
|
||||
}: {
|
||||
konnectSyncEnabled: boolean;
|
||||
isScratchPad: boolean;
|
||||
nonKonnectProjectLength: number;
|
||||
konnectProjectsLength: number;
|
||||
}) => {
|
||||
return (
|
||||
<TabList
|
||||
aria-label="Sidebar navigation"
|
||||
className="flex h-(--line-height-sm) w-full shrink-0 border-b border-solid border-b-(--hl-md)"
|
||||
>
|
||||
<Tab
|
||||
id="projects"
|
||||
className={`flex h-full shrink-0 items-center justify-between px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none ${konnectSyncEnabled ? 'cursor-pointer hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)' : 'text-(--color-font)!'}`}
|
||||
data-testid="sidebar-tab-projects"
|
||||
>
|
||||
{isScratchPad ? 'Projects' : `Projects (${nonKonnectProjectLength})`}
|
||||
</Tab>
|
||||
{konnectSyncEnabled && !isScratchPad && (
|
||||
<Tab
|
||||
id="konnect"
|
||||
className="flex h-full shrink-0 cursor-pointer items-center justify-between px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
|
||||
data-testid="sidebar-tab-konnect"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<KongLogo />
|
||||
Konnect ({konnectProjectsLength})
|
||||
</span>
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
);
|
||||
};
|
||||
|
||||
const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisabled?: boolean }) => (
|
||||
<BasicButton
|
||||
aria-label="Create new Project"
|
||||
onPress={onPress}
|
||||
isDisabled={isDisabled}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
|
||||
>
|
||||
<Icon icon="plus" className="h-2.5 w-2.5" />
|
||||
<span>New Project</span>
|
||||
</BasicButton>
|
||||
);
|
||||
|
||||
export const ProjectNavigationSidebar = ({
|
||||
konnectSyncEnabled,
|
||||
storageRules,
|
||||
konnectSyncEnabled,
|
||||
onCreateProject,
|
||||
}: ProjectNavigationSidebarProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -97,7 +151,7 @@ export const ProjectNavigationSidebar = ({
|
||||
requestId?: string;
|
||||
requestGroupId?: string;
|
||||
};
|
||||
const { userSession } = useRootLoaderData()!;
|
||||
const { userSession, settings } = useRootLoaderData()!;
|
||||
const projectLoaderData = useProjectLoaderData()!;
|
||||
const { projects, projectsSyncStatusPromise } = projectLoaderData;
|
||||
const [checkAllProjectSyncStatus] = useLoaderDeferData<Record<string, boolean>>(
|
||||
@@ -133,7 +187,7 @@ export const ProjectNavigationSidebar = ({
|
||||
);
|
||||
const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects');
|
||||
const isProjectTabActive = activeTab === 'projects';
|
||||
const { syncing, progress, error: syncError, startSync, cancelSync } = useKonnectSync();
|
||||
const { syncing, progress, startSync, cancelSync } = useKonnectSync();
|
||||
|
||||
const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId);
|
||||
const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null);
|
||||
@@ -153,6 +207,8 @@ export const ProjectNavigationSidebar = ({
|
||||
// ref to track whether we are currently fetching unsynced files for cloud sync projects to avoid duplicate requests
|
||||
const isFetchingUnsyncedFilesRef = useRef(false);
|
||||
|
||||
const syncKonnectProjectsAndNotifyRef = useRef<() => Promise<void>>(async () => {});
|
||||
|
||||
const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID;
|
||||
|
||||
const projectsWithPresence = useMemo(
|
||||
@@ -232,48 +288,66 @@ export const ProjectNavigationSidebar = ({
|
||||
return setUnsyncedFilesByProjectId(result);
|
||||
}, [organizationId, cloudSyncProjectIdsKey]);
|
||||
|
||||
const syncKonnectProjectsAndNotify = async () => {
|
||||
const result = await startSync(organizationId);
|
||||
setLastSyncResult(result ?? null);
|
||||
setShowSyncDetails(false);
|
||||
setCopiedReason(null);
|
||||
};
|
||||
syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify;
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!konnectSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runAndNotify = async () => {
|
||||
const result = await startSync(organizationId);
|
||||
showSkippedRoutesModal(result);
|
||||
};
|
||||
|
||||
const isResync = konnectProjects.length > 0;
|
||||
if (isResync) {
|
||||
showModal(AskModal, {
|
||||
title: 'Re-sync Konnect',
|
||||
title: 'Sync updates from Konnect',
|
||||
message: (
|
||||
<div>
|
||||
<p>Re-syncing will make the following changes:</p>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li>
|
||||
<strong>Reset</strong> — request method, URL, name, and Konnect-managed headers
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<KongLogo width={20} height={20} />
|
||||
<span className="text-sm font-medium">Konnect</span>
|
||||
</div>
|
||||
<span className="text-(--hl)">→</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<img src={insomniaLogo} alt="Insomnia" className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Insomnia</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-(--hl)">
|
||||
Sync the latest changes from your Konnect organization into Insomnia. This will:
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-(--color-font)" />
|
||||
Keep your local custom changes (never pushed to Konnect)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Delete</strong> — requests added manually or no longer in Konnect
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-(--color-font)" />
|
||||
Update existing resources to match Konnect
|
||||
</li>
|
||||
<li>
|
||||
<strong>Preserve</strong> — body, auth, query params, scripts, description, and user-added headers
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-(--color-font)" />
|
||||
Remove collections or environments tied to control planes, services, or routes deleted in Konnect
|
||||
</li>
|
||||
</ul>
|
||||
<p>This cannot be undone. Continue?</p>
|
||||
</div>
|
||||
),
|
||||
yesText: 'Re-sync',
|
||||
yesText: 'Sync Now',
|
||||
noText: 'Cancel',
|
||||
color: 'warning',
|
||||
color: 'surprise',
|
||||
onDone: async (confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
await runAndNotify();
|
||||
await syncKonnectProjectsAndNotify();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await runAndNotify();
|
||||
await syncKonnectProjectsAndNotify();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -705,191 +779,319 @@ export const ProjectNavigationSidebar = ({
|
||||
? [selectedItemId]
|
||||
: [];
|
||||
|
||||
const projectsActionButton = !isScratchPad ? (
|
||||
<Button
|
||||
aria-label="Create new Project"
|
||||
onPress={onCreateProject}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
|
||||
>
|
||||
<Icon icon="plus" className="h-2.5 w-2.5" />
|
||||
<span>New Project</span>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const konnectActionButton = syncing ? (
|
||||
<Button
|
||||
aria-label="Cancel sync"
|
||||
onPress={cancelSync}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
|
||||
>
|
||||
Cancel
|
||||
<Icon icon="stop-circle" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Sync Konnect"
|
||||
onPress={handleSync}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
|
||||
>
|
||||
Sync
|
||||
<Icon icon="refresh" />
|
||||
</Button>
|
||||
);
|
||||
const { hasKonnectPat } = settings;
|
||||
const showKonnectSyncIntro = konnectSyncEnabled && !isProjectTabActive && !hasKonnectPat;
|
||||
const [showKonnectConfigModal, setShowKonnectConfigModal] = useState(false);
|
||||
const [lastSyncResult, setLastSyncResult] = useState<SyncResult | null>(null);
|
||||
const [showSyncDetails, setShowSyncDetails] = useState(false);
|
||||
const [copiedReason, setCopiedReason] = useState<string | null>(null);
|
||||
const skippedRoutesByReason = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) {
|
||||
const list = map.get(reason) ?? [];
|
||||
list.push(`${routeName} — ${serviceName}`);
|
||||
map.set(reason, list);
|
||||
}
|
||||
return map;
|
||||
}, [lastSyncResult]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
|
||||
<SidebarHeader
|
||||
isScratchPad={isScratchPad}
|
||||
tabs={[
|
||||
{ name: 'projects', label: `Projects (${nonKonnectProjects.length})` },
|
||||
{
|
||||
name: 'konnect',
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<KongLogo />
|
||||
Konnect ({konnectProjects.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={tab => setActiveTab(tab)}
|
||||
filterValue={isProjectTabActive ? filterInputValue : konnectFilter || ''}
|
||||
onFilterChange={isProjectTabActive ? setFilterInputValue : setKonnectFilter}
|
||||
isFilterDisabled={false}
|
||||
actionButton={isProjectTabActive ? projectsActionButton : konnectActionButton}
|
||||
/>
|
||||
|
||||
{!isProjectTabActive && syncing && <p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>}
|
||||
{!isProjectTabActive && syncError && <p className="px-4 pb-1 text-xs text-(--color-danger)">{syncError}</p>}
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)"
|
||||
data-testid="project-navigation-tree-container"
|
||||
>
|
||||
<GridList
|
||||
aria-label="Project Navigation Tree"
|
||||
items={virtualizer.getVirtualItems()}
|
||||
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
|
||||
className="outline-hidden"
|
||||
selectedKeys={selectedKeys}
|
||||
selectionMode="single"
|
||||
dragAndDropHooks={sidebarDragAndDropHooks}
|
||||
>
|
||||
{virtualItem => {
|
||||
const item = visibleFlatItems[virtualItem.index];
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
// Prefix pinned-request to the key and id to ensure pinned items have a different key and id from non-pinned items with the same doc._id
|
||||
key={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${virtualItem.key}`}
|
||||
id={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`}
|
||||
textValue={item.doc.name || item.kind}
|
||||
onAuxClick={e => {
|
||||
if (e.button === 1 && item.kind === 'collectionChild') {
|
||||
e.preventDefault();
|
||||
tabNavigate(
|
||||
{
|
||||
organization: organizationId,
|
||||
project: item.project,
|
||||
workspace: item.workspace,
|
||||
item: item.doc,
|
||||
},
|
||||
{ withTab: true, shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPress={async e => {
|
||||
const docId = item.doc._id;
|
||||
if (item.kind === 'project') {
|
||||
if (routeInfo?.resourceId === docId) {
|
||||
toggleProjectOrWorkspace(docId);
|
||||
} else {
|
||||
!isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched });
|
||||
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
|
||||
}
|
||||
} else if (item.kind === 'workspace') {
|
||||
if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
|
||||
toggleProjectOrWorkspace(docId);
|
||||
} else {
|
||||
tabNavigate(
|
||||
{
|
||||
organization: organizationId,
|
||||
project: item.project,
|
||||
workspace: item.doc,
|
||||
item: item.doc,
|
||||
},
|
||||
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
|
||||
if (
|
||||
routeInfo?.resourceId === docId &&
|
||||
models.requestGroup.isRequestGroupId(docId) &&
|
||||
routeInfo?.routeId !== 'runner'
|
||||
) {
|
||||
toggleRequestGroups([docId], item.workspace);
|
||||
} else {
|
||||
tabNavigate(
|
||||
{
|
||||
organization: organizationId,
|
||||
project: item.project,
|
||||
workspace: item.workspace,
|
||||
item: item.doc,
|
||||
},
|
||||
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="group outline-hidden select-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item.kind === 'project' && (
|
||||
<ProjectNode item={item} onToggle={toggleProjectOrWorkspace} storageRules={storageRules} />
|
||||
<Tabs selectedKey={activeTab} onSelectionChange={key => setActiveTab(key as 'projects' | 'konnect')}>
|
||||
<SideBarTabList
|
||||
konnectSyncEnabled={konnectSyncEnabled}
|
||||
isScratchPad={isScratchPad}
|
||||
nonKonnectProjectLength={nonKonnectProjects.length}
|
||||
konnectProjectsLength={konnectProjects.length}
|
||||
/>
|
||||
</Tabs>
|
||||
{showKonnectSyncIntro ? (
|
||||
<KonnectSyncIntro onConfigure={() => setShowKonnectConfigModal(true)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between gap-1 p-(--padding-sm)">
|
||||
<SidebarSearchField
|
||||
value={isProjectTabActive ? filterInputValue : (konnectFilter ?? '')}
|
||||
isDisabled={projects.length === 0}
|
||||
onChange={isProjectTabActive ? setFilterInputValue : setKonnectFilter}
|
||||
/>
|
||||
{isProjectTabActive ? (
|
||||
!isScratchPad && <NewProjectButton onPress={onCreateProject} isDisabled={projects.length === 0} />
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{syncing ? (
|
||||
<Button
|
||||
aria-label="Cancel sync"
|
||||
onPress={cancelSync}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
<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>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Konnect settings"
|
||||
onPress={() => setShowKonnectConfigModal(true)}
|
||||
className="flex aspect-square h-full items-center justify-center 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="gear" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.kind === 'workspace' && (
|
||||
<WorkspaceNode
|
||||
item={item}
|
||||
onToggle={toggleProjectOrWorkspace}
|
||||
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
|
||||
onSortOrderChange={newSortOder => {
|
||||
if (item.doc.scope === 'collection') {
|
||||
setCollectionSortOrders(prev => {
|
||||
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
|
||||
return newCollectionSortOrders;
|
||||
});
|
||||
{!isProjectTabActive && syncing && (
|
||||
<p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)"
|
||||
data-testid="project-navigation-tree-container"
|
||||
>
|
||||
<GridList
|
||||
aria-label="Project Navigation Tree"
|
||||
items={virtualizer.getVirtualItems()}
|
||||
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
|
||||
className="outline-hidden"
|
||||
selectedKeys={selectedKeys}
|
||||
selectionMode="single"
|
||||
dragAndDropHooks={sidebarDragAndDropHooks}
|
||||
>
|
||||
{virtualItem => {
|
||||
const item = visibleFlatItems[virtualItem.index];
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
// Prefix pinned-request to the key and id to ensure pinned items have a different key and id from non-pinned items with the same doc._id
|
||||
key={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${virtualItem.key}`}
|
||||
id={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`}
|
||||
textValue={item.doc.name || item.kind}
|
||||
onAuxClick={e => {
|
||||
if (e.button === 1 && item.kind === 'collectionChild') {
|
||||
e.preventDefault();
|
||||
tabNavigate(
|
||||
{
|
||||
organization: organizationId,
|
||||
project: item.project,
|
||||
workspace: item.workspace,
|
||||
item: item.doc,
|
||||
},
|
||||
{ withTab: true, shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onPress={async e => {
|
||||
const docId = item.doc._id;
|
||||
if (item.kind === 'project') {
|
||||
if (routeInfo?.resourceId === docId) {
|
||||
toggleProjectOrWorkspace(docId);
|
||||
} else {
|
||||
!isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched });
|
||||
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
|
||||
}
|
||||
} else if (item.kind === 'workspace') {
|
||||
if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
|
||||
toggleProjectOrWorkspace(docId);
|
||||
} else {
|
||||
tabNavigate(
|
||||
{
|
||||
organization: organizationId,
|
||||
project: item.project,
|
||||
workspace: item.doc,
|
||||
item: item.doc,
|
||||
},
|
||||
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
|
||||
if (
|
||||
routeInfo?.resourceId === docId &&
|
||||
models.requestGroup.isRequestGroupId(docId) &&
|
||||
routeInfo?.routeId !== 'runner'
|
||||
) {
|
||||
toggleRequestGroups([docId], item.workspace);
|
||||
} else {
|
||||
tabNavigate(
|
||||
{
|
||||
organization: organizationId,
|
||||
project: item.project,
|
||||
workspace: item.workspace,
|
||||
item: item.doc,
|
||||
},
|
||||
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="group outline-hidden select-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item.kind === 'project' && (
|
||||
<ProjectNode item={item} onToggle={toggleProjectOrWorkspace} storageRules={storageRules} />
|
||||
)}
|
||||
|
||||
{item.kind === 'pinnedHeader' && <PinnedHeaderNode />}
|
||||
{item.kind === 'workspace' && (
|
||||
<WorkspaceNode
|
||||
item={item}
|
||||
onToggle={toggleProjectOrWorkspace}
|
||||
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
|
||||
onSortOrderChange={newSortOder => {
|
||||
if (item.doc.scope === 'collection') {
|
||||
setCollectionSortOrders(prev => {
|
||||
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
|
||||
return newCollectionSortOrders;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
|
||||
{item.kind === 'pinnedHeader' && <PinnedHeaderNode />}
|
||||
|
||||
{item.kind === 'pinnedRequest' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
|
||||
{item.kind === 'collectionChild' && (
|
||||
<RequestNode item={item} onToggleFolder={toggleRequestGroups} />
|
||||
)}
|
||||
|
||||
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
|
||||
{item.kind === 'pinnedRequest' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
|
||||
|
||||
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
|
||||
<EmptyNode item={item} storageRules={storageRules} />
|
||||
) : null}
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
|
||||
|
||||
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
|
||||
<EmptyNode item={item} storageRules={storageRules} />
|
||||
) : null}
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
{!isProjectTabActive && lastSyncResult && (
|
||||
<div
|
||||
className={`m-2 flex items-start justify-between gap-2 rounded-sm p-3 text-xs ${
|
||||
!lastSyncResult.success
|
||||
? 'bg-[rgba(58,18,8,1)]'
|
||||
: lastSyncResult.skippedRoutes.length > 0
|
||||
? 'bg-[rgba(250,173,20,0.15)]'
|
||||
: 'bg-[rgba(82,196,26,0.15)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Icon
|
||||
icon={
|
||||
lastSyncResult.success && lastSyncResult.skippedRoutes.length === 0
|
||||
? 'circle-check'
|
||||
: 'exclamation-triangle'
|
||||
}
|
||||
className={lastSyncResult.success && lastSyncResult.skippedRoutes.length === 0 ? 'mt-1.5' : 'mt-1'}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-(--color-font)">
|
||||
{lastSyncResult.success
|
||||
? lastSyncResult.skippedRoutes.length > 0
|
||||
? 'Sync complete, with warnings'
|
||||
: 'Sync complete'
|
||||
: 'Sync failed'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-(--hl)">
|
||||
{!lastSyncResult.success
|
||||
? lastSyncResult.error
|
||||
: lastSyncResult.routes.created === 0 &&
|
||||
lastSyncResult.routes.updated === 0 &&
|
||||
lastSyncResult.routes.deleted === 0 &&
|
||||
lastSyncResult.routes.skipped === 0
|
||||
? 'Already up-to-date with Konnect.'
|
||||
: [
|
||||
lastSyncResult.routes.created > 0 && `${lastSyncResult.routes.created} request(s) added`,
|
||||
lastSyncResult.routes.updated > 0 && `${lastSyncResult.routes.updated} request(s) updated`,
|
||||
lastSyncResult.routes.deleted > 0 && `${lastSyncResult.routes.deleted} request(s) deleted`,
|
||||
lastSyncResult.routes.skipped > 0 && `${lastSyncResult.routes.skipped} route(s) skipped`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ') + '.'}
|
||||
</p>
|
||||
{lastSyncResult.success && lastSyncResult.skippedRoutes.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className="mt-1 flex items-center gap-1 text-(--hl) hover:text-(--color-font)"
|
||||
onClick={() => setShowSyncDetails(prev => !prev)}
|
||||
>
|
||||
<Icon icon={showSyncDetails ? 'chevron-down' : 'chevron-right'} className="h-2.5 w-2.5" />
|
||||
{showSyncDetails ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
{showSyncDetails && (
|
||||
<div className="mt-2 max-h-48 space-y-2 overflow-y-auto">
|
||||
{[...skippedRoutesByReason.entries()].map(([reason, routes]) => {
|
||||
const MAX_SHOW = 5;
|
||||
const visible = routes.slice(0, MAX_SHOW);
|
||||
const extra = routes.length - MAX_SHOW;
|
||||
return (
|
||||
<div key={reason}>
|
||||
<p className="text-(--hl)">{reason} for the following routes:</p>
|
||||
<ul className="mt-1 space-y-0.5 pl-3">
|
||||
{visible.map(r => (
|
||||
<li key={r} className="list-disc text-(--color-font)">
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{extra > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2 pl-3 text-(--hl)">
|
||||
<span>+ {extra} more</span>
|
||||
<button
|
||||
className="underline hover:text-(--color-font)"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(routes.join('\n'));
|
||||
setCopiedReason(reason);
|
||||
setTimeout(() => setCopiedReason(null), 2000);
|
||||
}}
|
||||
>
|
||||
{copiedReason === reason ? 'Copied' : 'Copy full list'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="-mt-2 shrink-0 text-xl text-(--hl) hover:text-(--color-font)"
|
||||
onClick={() => setLastSyncResult(null)}
|
||||
>
|
||||
<Icon icon="close" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showKonnectConfigModal && (
|
||||
<KonnectSettingsModal
|
||||
onClose={() => setShowKonnectConfigModal(false)}
|
||||
syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -900,27 +1102,18 @@ export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreatePro
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
|
||||
<SidebarHeader
|
||||
isScratchPad={isScratchPad}
|
||||
tabs={[{ name: 'projects', label: 'Projects (0)' }]}
|
||||
activeTab="projects"
|
||||
onTabChange={() => {}}
|
||||
filterValue=""
|
||||
onFilterChange={() => {}}
|
||||
isFilterDisabled
|
||||
actionButton={
|
||||
!isScratchPad ? (
|
||||
<Button
|
||||
aria-label="Create new Project"
|
||||
onPress={onCreateProject}
|
||||
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
|
||||
>
|
||||
<Icon icon="plus" className="h-2.5 w-2.5" />
|
||||
<span>New Project</span>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Tabs>
|
||||
<SideBarTabList
|
||||
konnectSyncEnabled={false}
|
||||
isScratchPad={isScratchPad}
|
||||
nonKonnectProjectLength={0}
|
||||
konnectProjectsLength={0}
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex justify-between gap-1 p-(--padding-sm)">
|
||||
<SidebarSearchField value="" isDisabled onChange={() => {}} />
|
||||
{!isScratchPad && <NewProjectButton onPress={onCreateProject} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useInsomniaTab } from '~/ui/hooks/use-insomnia-tab';
|
||||
|
||||
import { type ChangeBufferEvent, type ChangeType, database } from '../../../common/database';
|
||||
import { debounce } from '../../../common/misc';
|
||||
import { INSOMNIA_TAB_HEIGHT } from '../../constant';
|
||||
import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context';
|
||||
import { type Size, useResizeObserver } from '../../hooks/use-resize-observer';
|
||||
import { Icon } from '../icon';
|
||||
@@ -373,7 +372,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = ''
|
||||
if (!tabList.length) return null;
|
||||
|
||||
return (
|
||||
<div className="box-content flex bg-(--color-bg)" style={{ height: `${INSOMNIA_TAB_HEIGHT + 1}px` }}>
|
||||
<div className="box-content flex h-(--line-height-sm) bg-(--color-bg)">
|
||||
<Button
|
||||
onPress={scrollLeft}
|
||||
isDisabled={leftScrollDisable}
|
||||
@@ -393,7 +392,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = ''
|
||||
disallowEmptySelection
|
||||
selectionMode="single"
|
||||
selectionBehavior="replace"
|
||||
className="flex h-[41px] w-fit"
|
||||
className="flex h-(--line-height-sm) w-fit"
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
items={tabList}
|
||||
dependencies={[issuesByWorkspaceId]}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
import type { SyncResult } from '../../konnect/sync';
|
||||
import { syncKonnect } from '../../konnect/sync';
|
||||
import { syncKonnect, zeroCounts } from '../../konnect/sync';
|
||||
import { AnalyticsEvent } from '../analytics';
|
||||
|
||||
const REVALIDATE_DEBOUNCE_MS = 500;
|
||||
@@ -10,25 +10,33 @@ const REVALIDATE_DEBOUNCE_MS = 500;
|
||||
interface KonnectSyncState {
|
||||
syncing: boolean;
|
||||
progress: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UseKonnectSyncResult {
|
||||
syncing: boolean;
|
||||
progress: string;
|
||||
error: string | null;
|
||||
startSync: (organizationId: string) => Promise<SyncResult | null>;
|
||||
startSync: (organizationId: string) => Promise<SyncResult>;
|
||||
cancelSync: () => void;
|
||||
}
|
||||
|
||||
export function useKonnectSync(): UseKonnectSyncResult {
|
||||
const [state, setState] = useState<KonnectSyncState>({ syncing: false, progress: '', error: null });
|
||||
const [state, setState] = useState<KonnectSyncState>({ syncing: false, progress: '' });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const revalidateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const startSync = async (organizationId: string): Promise<SyncResult | null> => {
|
||||
if (abortRef.current) { return null; }
|
||||
const startSync = async (organizationId: string): Promise<SyncResult> => {
|
||||
if (abortRef.current) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Sync already in progress.',
|
||||
controlPlanes: zeroCounts(),
|
||||
services: zeroCounts(),
|
||||
routes: zeroCounts(),
|
||||
skippedRoutes: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -36,10 +44,18 @@ export function useKonnectSync(): UseKonnectSyncResult {
|
||||
const pat = await window.main.secretStorage.getSecret('konnectPat');
|
||||
if (!pat) {
|
||||
abortRef.current = null;
|
||||
setState({ syncing: false, progress: '', error: 'No PAT found. Go to Preferences → Konnect to add one.' });
|
||||
return null;
|
||||
setState({ syncing: false, progress: '' });
|
||||
return {
|
||||
success: false,
|
||||
error: 'No Konnect PAT found.',
|
||||
controlPlanes: zeroCounts(),
|
||||
services: zeroCounts(),
|
||||
routes: zeroCounts(),
|
||||
skippedRoutes: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
setState({ syncing: true, progress: 'Starting sync...', error: null });
|
||||
setState({ syncing: true, progress: 'Starting sync...' });
|
||||
|
||||
const result = await syncKonnect({
|
||||
pat,
|
||||
@@ -47,20 +63,24 @@ export function useKonnectSync(): UseKonnectSyncResult {
|
||||
signal: controller.signal,
|
||||
onProgress: message => {
|
||||
setState(s => ({ ...s, progress: message }));
|
||||
if (revalidateTimerRef.current) { clearTimeout(revalidateTimerRef.current); }
|
||||
if (revalidateTimerRef.current) {
|
||||
clearTimeout(revalidateTimerRef.current);
|
||||
}
|
||||
revalidateTimerRef.current = setTimeout(revalidate, REVALIDATE_DEBOUNCE_MS);
|
||||
},
|
||||
});
|
||||
|
||||
abortRef.current = null;
|
||||
if (revalidateTimerRef.current) { clearTimeout(revalidateTimerRef.current); revalidateTimerRef.current = null; }
|
||||
if (revalidateTimerRef.current) {
|
||||
clearTimeout(revalidateTimerRef.current);
|
||||
revalidateTimerRef.current = null;
|
||||
}
|
||||
revalidate();
|
||||
|
||||
const cancelled = controller.signal.aborted;
|
||||
setState({
|
||||
syncing: false,
|
||||
progress: '',
|
||||
error: !result.success && !cancelled ? (result.error ?? 'Sync failed') : null,
|
||||
});
|
||||
|
||||
window.main.trackAnalyticsEvent({
|
||||
@@ -86,6 +106,10 @@ export function useKonnectSync(): UseKonnectSyncResult {
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
result.error = 'Sync cancelled by user.';
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -93,5 +117,5 @@ export function useKonnectSync(): UseKonnectSyncResult {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
return { syncing: state.syncing, progress: state.progress, error: state.error, startSync, cancelSync };
|
||||
return { syncing: state.syncing, progress: state.progress, startSync, cancelSync };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useUserService() {
|
||||
const isEssential = currentPlan?.type === 'free';
|
||||
const isEnterpriseLike = isEnterpriseOwner || isEnterpriseMember;
|
||||
const isTrailing = currentPlan?.status === 'trialing';
|
||||
const isPaidEnterprise = isEnterpriseLike && !isTrailing;
|
||||
const trialDaysLeft: number | null =
|
||||
isTrailing && currentPlan?.trialingEnd ? diffInDayCeil(new Date(currentPlan?.trialingEnd), new Date()) : null;
|
||||
return {
|
||||
@@ -17,6 +18,7 @@ export function useUserService() {
|
||||
isEnterpriseMember,
|
||||
isEssential,
|
||||
isEnterpriseLike,
|
||||
isPaidEnterprise,
|
||||
canUpgrade: !isEnterpriseLike,
|
||||
displayName: [user?.first_name, user?.last_name].filter(Boolean).join(' ') || user?.email,
|
||||
isTrailing,
|
||||
|
||||
Reference in New Issue
Block a user