Compare commits

...

50 Commits

Author SHA1 Message Date
ea9a56eb65 Display DNS deployment URLs in overview section (#21)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Disable `Deploy` button in configure step if account and deployer not selected
- Update organization slug
- Only display project if current user is project owner

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#21
2024-10-30 13:11:04 +00:00
05bd766133 Display project URLs in Overview tab (#20)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Fix project create not working after failed tx
- Poll for project details for auction details
- Update wallet connect metadata

![image](/attachments/cd0217c9-8a2f-4bc5-ad4c-2654fa92f958)

Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#20
2024-10-29 14:10:01 +00:00
0f18bc978e Pass payment tx hash in deployment request (#19)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#19
2024-10-29 09:12:39 +00:00
519e318190 Check if repo with same name already exists when creating project (#18)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

![image](/attachments/6e0efb39-db83-4140-b840-3eca84c3e0f2)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#18
2024-10-28 11:23:22 +00:00
63969ae25a Implement payments for app deployments (#17)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)
- Implement funtionality to pay for deployments by connecting wallet using `WalletConnect`

![image](/attachments/842e33e8-7de6-4d91-9008-1c67a259b586)

![image](/attachments/94b2fe39-f753-4e99-a8c2-bda4c0b84897)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#17
2024-10-28 09:46:18 +00:00
b449c299dc Comment out bugsnag code (#16)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Reviewed-on: cerc-io/snowballtools-base#16
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2024-10-25 12:40:34 +00:00
2a35ec1cd5 Check deployment status while creating project with single deployer (#15)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)
- Use deployer API to get status of the deployments

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#15
Co-authored-by: Nabarun Gogoi <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun Gogoi <nabarun@deepstacksoft.com>
2024-10-25 10:47:04 +00:00
be90fc76c1 Update script to pay webapp deployer before deployment request (#14)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#14
2024-10-25 10:01:22 +00:00
3fa60f3cdf Handle account sequence mismatch error (#13)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Handle failed txs due to `account sequence mismatch` error by creating a wrapper for all tx methods and retry the tx if `account sequence mismatch` error occurs

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#13
2024-10-24 11:38:17 +00:00
3d9aedeb7e List deployer LRNs in deployment configuration step (#11)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Fix request Id being set to `null` while fetching build logs
- Populate deployer LRNs dropdown with LRNs fetched from registry in configure delpoyment step

![image](/attachments/ff421bdf-6e0b-443e-9dc8-455bde481b4f)

![image](/attachments/87c9bce3-3743-4f4a-a997-a02a3504e61e)

![image](/attachments/dd442fe6-ad30-4723-a2bb-0723ad3eb3c9)

![image](/attachments/37f0da01-671f-4e3a-92e4-b34e25566a0d)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#11
2024-10-23 15:36:19 +00:00
096318cf13 Display build logs only when available (#10)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#10
2024-10-22 12:43:20 +00:00
27ef859075 Remove organization switcher from side bar (#9)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)
- Display DNS URLs in overview tab

Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#9
2024-10-22 10:16:35 +00:00
5152952a45 Display deployment build logs (#8)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#8
2024-10-22 09:12:59 +00:00
ef26f9b39e Implement functionality to release funds after deployment (#7)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Implement functionality to release funds after first successful deployment

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#7
2024-10-21 14:25:49 +00:00
d486f44cfe Update UI to take environment variables from user (#6)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Take environment variables from the user in the `Configure` deployment step

Co-authored-by: Isha Venikar <ishavenikar@Ishas-MacBook-Air.local>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#6
2024-10-21 11:05:35 +00:00
5c9c7575f2 Set user email with ETH address while authenticating (#5)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Reviewed-on: cerc-io/snowballtools-base#5
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2024-10-19 13:06:09 +00:00
59a164f3f8 Update frontend deployment script (#3)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Reviewed-on: cerc-io/snowballtools-base#3
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2024-10-18 12:53:24 +00:00
bc52b34462 Implement authentication with SIWE (#4)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Remove LIT authentication

Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#4
2024-10-18 12:47:11 +00:00
5aefda1248 Integrate SP auctions for app deployment (#2)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)
- Add support for configuring web-app deployers by -
  - Configuring deployer LRN (for targeted deployments)
  - Configuring SP auction params for deployment auction (max price and number of providers)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#2
2024-10-18 12:37:01 +00:00
42bdd21089 Upgrade from laconic-sdk to registry-sdk (#1)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#1
2024-10-16 08:43:51 +00:00
Gilbert
e5a00016c1
Merge pull request #234 from snowball-tools/ng-deployment-test
Increase retry interval for checking deployment URL in CI test
2024-09-18 23:21:33 -05:00
13b912d318 Increase retry interval for checking deployment URL 2024-08-23 11:09:12 +05:30
059863c4b9
Merge pull request #233 from snowball-tools/dboreham-frontend-deploy-from-gh
Deploy frontend directly from github repo
2024-07-05 09:42:05 -06:00
58906844cc Deploy directly from github repo 2024-07-05 09:36:05 -06:00
Vivian Phung
9f0a2ad548
space 2024-07-03 12:41:39 -04:00
Vivian Phung
bd10e2cb35
fix: version temp 2024-06-25 01:34:21 -04:00
Vivian Phung
eb32385cf3
revert fix 2024-06-25 01:14:08 -04:00
Vivian Phung
aebb20b987
update README.md debugging deployer 2024-06-25 01:08:38 -04:00
Vivian Phung
8a2b51952f
fix: temp frontend (#230)
* v.0.0.9 staging

* fix: temp frontend deployment bug
2024-06-25 01:04:03 -04:00
Vivian Phung
94f46f9621
v.0.0.9 staging (#229) 2024-06-24 19:52:51 -04:00
Vivian Phung
e751addcce
Update local.toml.example in packages/backend (#228)
### TL;DR

Added new keys for Google and Turnkey integration in `local.toml.example`

### What changed?

- Updated server session secret to empty string
- Added placeholders for google clientId and clientSecret
- Added placeholders for Turnkey API integration (apiBaseUrl, apiPrivateKey, apiPublicKey, defaultOrganizationId)

### How to test?

1. Pull the latest changes
2. Update `local.toml.example` file with actual values.
3. Run the application locally and ensure all services are working correctly.

### Why make this change?

To integrate new services (Google and Turnkey) into the project setup.

---
2024-06-24 19:42:33 -04:00
Vivian Phung
c01f8fdabf
Refactor: components/shared (#227)
### TL;DR

This pull request consolidates all shared component exports into a new `index.ts` file within the `components/shared` directory. The purpose is to streamline and centralize the exports of shared components for improved maintainability.

### What changed?

- Created a new `index.ts` file in the `components/shared` directory that exports all shared components.
- Updated import statements to use the new centralized `index.ts` file instead of individual component files.

### How to test?

1. Run the frontend application.
2. Navigate through the application to ensure all shared components render correctly without console errors.

### Why make this change?

The motivation behind this change is to organize exports of shared components in a single file. This enhances code maintainability and simplifies the process of importing shared components across the project.

---
2024-06-24 19:36:08 -04:00
Vivian Phung
2b60114dab
refactor: use onToast remove react-hot-toast dep (#226)
### TL;DR

Removed the dependency on `react-hot-toast` in favor of a custom implementation for toast notifications.

### What changed?

1. Removed `react-hot-toast` from `package.json` and `yarn.lock`. 
2. Updated `EnvironmentVariables` and `Config` to use the custom toast notification system.

### Why make this change?

To reduce bundle size and have more control over the toast notification system.

### How to test?

1. Navigate to the Environment Variables settings for a project. Try adding and removing environment variables to see the new toast notifications in action.
2. Navigate to the Domains settings for a project and try adding a new domain to view the custom toast notifications.

---
2024-06-24 19:25:31 -04:00
Vivian Phung
9a1c0e8338
feat(domains): DomainCard and WebhookCard styling start (#225)
### TL;DR

Refactored the `DomainCard`, `EditDomainDialog`, and `WebhookCard` components to improve code readability and enhance UI using new shared components like `Tag`, `Heading`, `Button`, and `CustomIcon`.

### What changed?

- `DomainCard` component:
  - Replaced `Chip` with `Tag` component.
  - Used `Heading`, `Button`, and `CustomIcon` components.
  - Updated refresh icon to show `LoadingIcon` when checking.
- `EditDomainDialog` component:
  - Used `useToast` hook for toast messages.
- `WebhookCard` component:
  - Used `Input`, `Button`, and `CustomIcon` components for better UI.
- Added Storybook stories for the updated components.

### How to test?

1. Go to the project settings page.
2. Verify the `DomainCard` UI updates.
3. Edit a domain and check the toasts.
4. Verify the `WebhookCard` UI and functionality.
5. Run Storybook and inspect the added stories for the components.

### Why make this change?

To improve the consistency and user experience of the project settings UI, and to make the components more maintainable by using shared components.

---
2024-06-24 19:22:20 -04:00
Vivian Phung
1b038476c7
feat: Settings screen for org (#224)
This update introduces a new header section within the `Settings` page. It incorporates a `Heading` component from the shared components library, ensuring consistent styling and structure across the app. The `Settings` page layout now features appropriate padding and flexbox for better alignment and spacing.

---
2024-06-24 18:57:38 -04:00
Vivian Phung
4a78eb13f6
fix: ProjectSearchBarDialog (search small screen) Suggestions once (#223)
Refactor the rendering logic of the suggestion list in `ProjectSearchBarDialog` component. This change simplifies the conditional rendering by restructuring the JSX to be more readable and maintainable. Now, the 'Suggestions' label is rendered once if there are items, and the items are mapped afterward.

---
2024-06-24 18:54:32 -04:00
Vivian Phung
41bcb2e7d0
fix: ProjectSearchBarDialog suppressRefError (#222)
This PR resolves issues with the Project Search Bar component where `getMenuProps` was causing reference errors. By adding `suppressRefError: true` to `getMenuProps` in both `ProjectSearchBar` and `ProjectSearchBarDialog`, the warnings are suppressed.

---
2024-06-24 18:51:31 -04:00
Vivian Phung
f981f1a3f6
fix(ProjectSearchBarDialog): getMenuProps error (#221)
- Replaced `useDebounce` with `useDebounceValue` for better type inference and simplicity
- Added `getMenuProps` to `useCombobox` to support better accessibility and usability
- Minor style tweak to improve `ProjectSearch` header hover effect
- Created Storybook stories for the `ProjectSearchBar` component

---
2024-06-24 18:48:32 -04:00
Vivian Phung
dee84f18cb
feat: dynamic project success page and update links (#220)
### TL;DR

Integrates project data fetching for dynamic subdomain display on the Project Deployment Success page and the OverviewTabPanel.

### What changed?

- Updated `Id.tsx` to fetch project data and dynamically display project's subdomain after deployment.
- Modified `Overview.tsx` to make project's subdomain a clickable link.

### How to test?

1. Deploy a new project and check the deployment success page for correct subdomain display.
2. Open a project's overview tab and click the subdomain link to ensure it navigates correctly.

### Why make this change?

Improves user experience by displaying the actual subdomain and making it clickable, ensuring users can conveniently verify their deployment and access project domain.

---
2024-06-24 18:44:34 -04:00
Vivian Phung
44015d5451
feat(ui): loading spinner on project creation (#219)
### TL;DR
This pull request adds a loading icon to the 'Deploy' button in the project template creation form.

### What changed?
- Imported `LoadingIcon` from `components/shared/CustomIcon`.
- Modified the `Button` component to conditionally display the `LoadingIcon` when `isLoading` is `true`. The icon will animate by spinning.

### How to test?
1. Navigate to the project template creation form.
2. Fill in the required fields.
3. Click the 'Deploy' button.
4. Ensure the loading icon appears and spins when the button is disabled (when `isLoading` is `true`).

### Why make this change?
This change provides visual feedback to users, indicating that their action is being processed, thereby enhancing user experience.

---
2024-06-24 18:41:31 -04:00
Vivian Phung
a684743bd6
feat(template projects): generate git repo on backend (#218)
### TL;DR

- Still cretaes app if user migrates from page

The PR introduces a new `AddProjectFromTemplate` mutation to facilitate project creation using a repository template. This change centralizes the template project creation logic within the backend, improving code maintainability by removing redundant client-side code.

### What changed?
- Added `AddProjectFromTemplate` input type in `schema.gql` and corresponding TypeScript interfaces.
- Implemented `addProjectFromTemplate` resolver with error handling and Octokit integration for repository creation.
- Updated `service.ts` to include the new `addProjectFromTemplate` method.
- Created new GraphQL `Mutation` for `addProjectFromTemplate` in the GraphQL schema.
- Adjusted the client-side GQLClient to support the new mutation.
- Modified frontend to utilize the new backend mutation for project creation from a template.

### How to test?
1. Ensure your backend server is running.
2. Use a GraphQL client like Postman to call the `addProjectFromTemplate` mutation with appropriate input.
3. Verify that the new project is created using the specified template, and appropriate error messages are returned for failures.
4. Check the frontend flow for creating a project from a template to ensure it is working correctly.

### Why make this change?
This change enhances code maintainability by centralizing template project creation logic within the backend, thereby reducing redundancy and potential inconsistencies in client-side implementations.

---
2024-06-24 18:38:01 -04:00
Vivian Phung
b12c95b2ff
fix(readme) 2024-06-24 15:00:45 -04:00
Vivian Phung
a4d9211ffe
Refactor(README.md) (#217)
This pull request refactors the main `README.md` by removing detailed backend and frontend setup instructions and adding separate `README.md` files for the backend and frontend with their specific setup and deployment instructions. This makes the main `README.md` cleaner and directs users to specific READMEs for backend and frontend setups.

---
2024-06-22 18:07:47 -04:00
Vivian Phung
af31fac3ee
chore(storybook): icons to correct folder (#216)
This pull request refactors several aspects of the frontend codebase focused on Storybook configurations and icon story files. It replaces `args` with `argTypes` to enhance control configurations, adds `staticDirs` for public assets in Storybook, and standardizes the titles of some icon stories.
2024-06-22 17:27:37 -04:00
Vivian Phung
54ae3f429d
fix(staging): github client id on frontend staging deployment script (#214)
### TL;DR
Update references to the new GitHub repository URLs and make minor formatting fixes in deployment scripts.

### What changed?
Updated GitHub repository URLs from `snowball-tools-platform` to `snowball-tools` in various deployment and test scripts. Made minor formatting adjustments including spacing and indentation.

### How to test?
Run the deployment scripts in their respective environments to ensure they reference the correct repository URLs and all functionalities work as expected. Check for successful creation and updation of application records.

### Why make this change?
This change was made to reflect the new repository structure and ensure consistency across all deployment scripts. The minor formatting fixes improve code readability.

---
2024-06-22 17:24:40 -04:00
Vivian Phung
acfe78bf07
chore: ignore .DS_Store 2024-06-22 17:05:34 -04:00
Vivian Phung
ce1833cb51
bump: version (#213)
This pull request updates the backend version endpoint to return version '0.0.8' instead of '0.0.7'.

---
2024-06-21 21:16:34 -04:00
Vivian Phung
f2e59c11fd
Refactor: VerifyCodeInput Component and Modify Access Code Validation (#212)
### TL;DR

Implemented the new `VerifyCodeInput` component and updated the access code validation logic.

### What changed?

1. Added a new reusable `VerifyCodeInput` component for verifying codes in a user-friendly way. This component handles paste events, input changes, and keyboard navigation.
2. Updated the backend route `/accesscode` to accept an arbitrary code for now. 
3. Incorporated the `VerifyCodeInput` component into the `AccessCode` page, replacing the generic `Input` component.
4. Updated the access code validation logic to check for a trimmed length of 5 characters instead of 6.
5. Added a slight pause for UX purposes when validating the access code on the frontend.

### How to test?

1. Go to the Access Code page.
2. Try entering an access code with various inputs (keyboard, paste, etc.) to see if it works seamlessly.
3. Verify that only a 5-digit code is considered valid.
4. Check the backend logs to ensure the validation endpoint is working as expected.

### Why make this change?

This change improves the user experience by providing a custom input component for access code verification and ensures that the access code validation meets the new requirements.

---
2024-06-21 21:13:32 -04:00
Vivian Phung
b261e7e436
Feat: Access Code Authentication Flow (#211)
## What changed?

This Pull Request introduces an access code validation feature to the authentication process. Changes encompass backend route for access code validation, new frontend components for handling access code input, and integration of the access code verification in the signup flow.

### Backend:
- Added POST `/accesscode` route in `auth.ts` for validating access codes.

### Frontend:
- Created `AccessCode` component for access code input and validation.
- Added `AccessSignUp` component that integrates access code verification before signup.
- Updated `SignUp` component to check for valid access code on mount.
- Modified `SnowballAuth` to use new `AccessSignUp` instead of `SignUp`.
- Added `verifyAccessCode` utility function for code verification API call.

## How to test?
1. Run the backend and frontend projects.
2. Navigate to the signup page. You should be prompted to enter an access code.
3. Enter the code `444444` and proceed. Any other code should display an error message.
4. Verify that valid access code routes to the signup component.

## Why make this change?

This change improves authentication by adding an extra layer of security through access code verification.
2024-06-21 21:10:31 -04:00
Vivian Phung
934aa1a26b
Refactor: Env to utils/constants (#210)
This PR centralizes all the environment variable references into a single constants file. The change includes replacing various `import.meta.env` references with imports from the new `utils/constants` module. This improves maintainability by providing a single place to manage environment variables.
2024-06-21 21:07:41 -04:00
131 changed files with 6440 additions and 5902 deletions

View File

@ -0,0 +1,29 @@
name: Lint
on:
pull_request:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v2
- name: Download yarn
run: |
curl -fsSL -o /usr/local/bin/yarn https://github.com/yarnpkg/yarn/releases/download/v1.22.21/yarn-1.22.21.js
chmod +x /usr/local/bin/yarn
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: yarn
- name: Build libs
run: yarn workspace gql-client run build
- name: Linter check
run: yarn lint

5
.gitignore vendored
View File

@ -6,4 +6,7 @@ yarn-error.log
packages/backend/environments/local.toml
packages/backend/dev/
packages/frontend/dist/
packages/frontend/dist/
# ignore all .DS_Store files
**/.DS_Store

223
README.md
View File

@ -1,220 +1,23 @@
# snowballtools-base
## Setup
This is a [yarn workspace](https://yarnpkg.com/features/workspaces) monorepo for the dashboard.
- Clone the `snowballtools-base` repo
## Getting Started
```bash
git clone git@github.com:snowball-tools/snowballtools-base.git
cd snowballtools-base
```
### Install dependencies
- Install dependencies in root
In the root of the project, run:
```bash
yarn
```
```zsh
yarn
```
- Build packages
### Build backend
```bash
yarn build --ignore frontend
```
```zsh
yarn build --ignore frontend
```
Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
### Environment variables, running the development server, and deployment
- Configuration Files
- For Backend:
```bash
cp packages/backend/environments/local.toml.example packages/backend/environments/local.toml
```
- Production
- Update the following in backend [config file](packages/backend/environments/local.toml)
```toml
[server]
...
[server.session]
# Secret should be changed to a different random string
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7sidnc382"
# Set URL of the frontend app
appOriginUrl = "https://dashboard.snowballtools.com"
# Set to true for session cookies to work behind proxy
trustProxy = true
# Set empty domain when using secure connection
domain = ""
```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- Homepage URL: `https://dashboard.snowballtools.com`
- Authorization callback URL: `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
[gitHub]
webhookUrl = "https://api.snowballtools.com"
```
- Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
```toml
[registryConfig]
fetchDeploymentRecordDelay = 5000
# Use actual port for REST endpoint
restEndpoint = "http://api.laconic.com:1317"
# Use actual port for GQL endpoint
gqlEndpoint = "http://api.laconic.com:9473/api"
# Set private key of account to be used in Laconicd
privateKey = "0wtu92cd4f1y791ezpjwgzzazni4dmd3q3mzqc3t6i6r9v06ji784tey6hwmnn69"
# Set Bond ID to be used for publishing records
bondId = "8xk8c2pb61kajwixpm223zvptr2x2ncajq0vd998p6aqhvqqep2reu6pik245epf"
chainId = "laconic_9000-1"
# Set authority that is existing in the chain
authority = "laconic"
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
```
- Development
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- Homepage URL: `http://localhost:3000`
- Authorization callback URL: `http://localhost:3000/organization/projects/create`
- Generate a new client secret after app is created
- Setup Laconicd
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
```bash
laconic-so deployment --dir laconic-loaded-deployment exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
```
- Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml)
- Run the script to create bond, reserve the authority and set authority bond
```bash
yarn test:registry:init
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
```
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
- Setup ngrok for GitHub webhooks
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
- Start ngrok and point to backend server endpoint
```bash
ngrok http http://localhost:8000
```
- Look for the forwarding URL in ngrok
```bash
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
```
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
[gitHub]
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
```
- For Frontend:
```bash
cp packages/frontend/.env.example packages/frontend/.env
```
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
```env
VITE_GITHUB_CLIENT_ID = <CLIENT_ID>
```
- Set `VITE_GITHUB_PWA_TEMPLATE_REPO` and `VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
```env
VITE_GITHUB_PWA_TEMPLATE_REPO = 'cerc-io/test-progressive-web-app' # Set actual owner/name of the template repo that will be used for creating new repo
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'cerc-io/image-upload-pwa-example' # Set actual owner/name of the template repo that will be used for creating new repo
```
- Production
- Set the following values in [.env](packages/frontend/.env) file
```env
VITE_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint
```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Set project home page URL to `https://dashboard.snowballtools.com`
- On creation of project, use the `Project ID` and set it in `VITE_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
VITE_WALLET_CONNECT_ID = <PROJECT_ID>
```
- Build the React application
```bash
yarn build
```
- Use a web server for hosting static built files
```bash
python3 -m http.server -d build 3000
```
- Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
VITE_SERVER_URL = 'http://localhost:8000'
```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID.
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Project home page URL is not required to be set
- On creation of project, use the `Project ID` and set it in `VITE_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
VITE_WALLET_CONNECT_ID = <Project_ID>
```
- The React application will be running in `http://localhost:3000/`
## Development
- Start the backend server
```bash
yarn start:backend
```
- Start the frontend
```bash
yarn start
```
Follow the instructions in the README.md files of the [backend](packages/backend/README.md) and [frontend](packages/frontend/README.md) packages.

View File

@ -15,8 +15,8 @@ VITE_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_github_clientid'
VITE_GITHUB_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_pwa_templaterepo'
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo'
VITE_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_wallet_connect_id'
VITE_LACONICD_CHAIN_ID = 'LACONIC_HOSTED_CONFIG_laconicd_chain_id'
VITE_LIT_RELAY_API_KEY = 'LACONIC_HOSTED_CONFIG_lit_relay_api_key'
VITE_ALCHEMY_API_KEY = 'LACONIC_HOSTED_CONFIG_aplchemy_api_key'
VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key'
VITE_PASSKEY_WALLET_RPID = 'LACONIC_HOSTED_CONFIG_passkey_wallet_rpid'
VITE_TURNKEY_API_BASE_URL = 'LACONIC_HOSTED_CONFIG_turnkey_api_base_url'
@ -24,7 +24,7 @@ VITE_TURNKEY_ORGANIZATION_ID = 'LACONIC_HOSTED_CONFIG_turnkey_organization_id'
EOF
yarn || exit 1
yarn build || exit 1
yarn build --ignore backend || exit 1
if [[ ! -d "$OUTPUT_DIR" ]]; then
echo "Missing output directory: $OUTPUT_DIR" 1>&2

View File

@ -1 +1,76 @@
# Backend for Snowball Tools
# backend
This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces).
## Getting Started
### Install dependencies
In the root of the project, run:
```zsh
yarn
```
### Build backend
```zsh
yarn build --ignore frontend
```
### Environment variables
#### Local
Copy the `environments/local.toml.example` file to `environments/local.toml`:
```zsh
cp environments/local.toml.example environments/local.toml
```
#### Staging environment variables
In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml)
#### Production environment variables
In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml)
### Run development server
```zsh
yarn start
```
## Deployment
Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments):
```zsh
git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git
```
### Staging
```zsh
echo trigger >> .gitea/workflows/triggers/staging-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Production
```zsh
echo trigger >> .gitea/workflows/triggers/production-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Deployment status
Dumb for now
- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version)
- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version)
Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts)

View File

@ -3,9 +3,12 @@
port = 8000
gqlPath = "/graphql"
[server.session]
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi"
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
@ -19,6 +22,7 @@
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
@ -26,9 +30,14 @@
bondId = ""
authority = ""
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
gas = ""
fees = ""
gasPrice = "1alnt"
[misc]
projectDomain = "apps.snowballtools.com"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"

View File

@ -4,10 +4,10 @@
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@cerc-io/registry-sdk": "^0.2.11",
"@graphql-tools/schema": "^10.0.2",
"@graphql-tools/utils": "^10.0.12",
"@octokit/oauth-app": "^6.1.0",
"@snowballtools/laconic-sdk": "^0.1.17",
"@turnkey/sdk-server": "^0.1.0",
"@types/debug": "^4.1.5",
"@types/express": "^4.17.21",

View File

@ -34,16 +34,21 @@ export interface RegistryConfig {
privateKey: string;
bondId: string;
fetchDeploymentRecordDelay: number;
checkAuctionStatusDelay: number;
authority: string;
fee: {
amount: string;
denom: string;
gas: string;
fees: string;
gasPrice: string;
};
}
export interface MiscConfig {
projectDomain: string;
export interface AuctionConfig {
commitFee: string;
commitsDuration: string;
revealFee: string;
revealsDuration: string;
denom: string;
}
export interface Config {
@ -51,7 +56,7 @@ export interface Config {
database: DatabaseConfig;
gitHub: GitHubConfig;
registryConfig: RegistryConfig;
misc: MiscConfig;
auction: AuctionConfig;
turnkey: {
apiBaseUrl: string;
apiPublicKey: string;

View File

@ -3,7 +3,9 @@ import {
DeepPartial,
FindManyOptions,
FindOneOptions,
FindOptionsWhere
FindOptionsWhere,
IsNull,
Not
} from 'typeorm';
import path from 'path';
import debug from 'debug';
@ -11,7 +13,7 @@ import assert from 'assert';
import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig, MiscConfig } from './config';
import { DatabaseConfig } from './config';
import { User } from './entity/User';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
@ -21,6 +23,7 @@ import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Domain } from './entity/Domain';
import { getEntities, loadAndSaveData } from './utils';
import { UserOrganization } from './entity/UserOrganization';
import { Deployer } from './entity/Deployer';
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json';
@ -31,9 +34,8 @@ const nanoid = customAlphabet(lowercase + numbers, 8);
// TODO: Fix order of methods
export class Database {
private dataSource: DataSource;
private projectDomain: string;
constructor ({ dbPath } : DatabaseConfig, { projectDomain } : MiscConfig) {
constructor({ dbPath }: DatabaseConfig) {
this.dataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
@ -41,11 +43,9 @@ export class Database {
synchronize: true,
logging: false
});
this.projectDomain = projectDomain;
}
async init (): Promise<void> {
async init(): Promise<void> {
await this.dataSource.initialize();
log('database initialized');
@ -58,21 +58,21 @@ export class Database {
}
}
async getUser (options: FindOneOptions<User>): Promise<User | null> {
async getUser(options: FindOneOptions<User>): Promise<User | null> {
const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.findOne(options);
return user;
}
async addUser (data: DeepPartial<User>): Promise<User> {
async addUser(data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.save(data);
return user;
}
async updateUser (user: User, data: DeepPartial<User>): Promise<boolean> {
async updateUser(user: User, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User);
const updateResult = await userRepository.update({ id: user.id }, data);
assert(updateResult.affected);
@ -80,7 +80,7 @@ export class Database {
return updateResult.affected > 0;
}
async getOrganizations (
async getOrganizations(
options: FindManyOptions<Organization>
): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization);
@ -89,7 +89,7 @@ export class Database {
return organizations;
}
async getOrganization (
async getOrganization(
options: FindOneOptions<Organization>
): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization);
@ -98,7 +98,7 @@ export class Database {
return organization;
}
async getOrganizationsByUserId (userId: string): Promise<Organization[]> {
async getOrganizationsByUserId(userId: string): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization);
const userOrgs = await organizationRepository.find({
@ -114,21 +114,21 @@ export class Database {
return userOrgs;
}
async addUserOrganization (data: DeepPartial<UserOrganization>): Promise<UserOrganization> {
async addUserOrganization(data: DeepPartial<UserOrganization>): Promise<UserOrganization> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization);
const newUserOrganization = await userOrganizationRepository.save(data);
return newUserOrganization;
}
async getProjects (options: FindManyOptions<Project>): Promise<Project[]> {
async getProjects(options: FindManyOptions<Project>): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project);
const projects = await projectRepository.find(options);
return projects;
}
async getProjectById (projectId: string): Promise<Project | null> {
async getProjectById(projectId: string): Promise<Project | null> {
const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository
@ -140,7 +140,9 @@ export class Database {
)
.leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.domain', 'domain')
.leftJoinAndSelect('deployments.deployer', 'deployer')
.leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', {
projectId
@ -150,7 +152,25 @@ export class Database {
return project;
}
async getProjectsInOrganization (
async allProjectsWithoutDeployments(): Promise<Project[]> {
const allProjects = await this.getProjects({
where: {
auctionId: Not(IsNull()),
},
relations: ['deployments'],
withDeleted: true,
});
const projects = allProjects.filter(project => {
if (project.deletedAt !== null) return false;
return project.deployments.length === 0;
});
return projects;
}
async getProjectsInOrganization(
userId: string,
organizationSlug: string
): Promise<Project[]> {
@ -181,7 +201,7 @@ export class Database {
/**
* Get deployments with specified filter
*/
async getDeployments (
async getDeployments(
options: FindManyOptions<Deployment>
): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
@ -190,12 +210,13 @@ export class Database {
return deployments;
}
async getDeploymentsByProjectId (projectId: string): Promise<Deployment[]> {
async getDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
domain: true,
createdBy: true
createdBy: true,
deployer: true,
},
where: {
project: {
@ -208,7 +229,7 @@ export class Database {
});
}
async getDeployment (
async getDeployment(
options: FindOneOptions<Deployment>
): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
@ -217,14 +238,14 @@ export class Database {
return deployment;
}
async getDomains (options: FindManyOptions<Domain>): Promise<Domain[]> {
async getDomains(options: FindManyOptions<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find(options);
return domains;
}
async addDeployment (data: DeepPartial<Deployment>): Promise<Deployment> {
async addDeployment(data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const id = nanoid();
@ -238,7 +259,7 @@ export class Database {
return deployment;
}
async getProjectMembersByProjectId (
async getProjectMembersByProjectId(
projectId: string
): Promise<ProjectMember[]> {
const projectMemberRepository =
@ -259,7 +280,7 @@ export class Database {
return projectMembers;
}
async getEnvironmentVariablesByProjectId (
async getEnvironmentVariablesByProjectId(
projectId: string,
filter?: FindOptionsWhere<EnvironmentVariable>
): Promise<EnvironmentVariable[]> {
@ -278,7 +299,7 @@ export class Database {
return environmentVariables;
}
async removeProjectMemberById (projectMemberId: string): Promise<boolean> {
async removeProjectMemberById(projectMemberId: string): Promise<boolean> {
const projectMemberRepository =
this.dataSource.getRepository(ProjectMember);
@ -293,7 +314,7 @@ export class Database {
}
}
async updateProjectMemberById (
async updateProjectMemberById(
projectMemberId: string,
data: DeepPartial<ProjectMember>
): Promise<boolean> {
@ -307,7 +328,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async addProjectMember (
async addProjectMember(
data: DeepPartial<ProjectMember>
): Promise<ProjectMember> {
const projectMemberRepository =
@ -317,7 +338,7 @@ export class Database {
return newProjectMember;
}
async addEnvironmentVariables (
async addEnvironmentVariables(
data: DeepPartial<EnvironmentVariable>[]
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
@ -328,7 +349,7 @@ export class Database {
return savedEnvironmentVariables;
}
async updateEnvironmentVariable (
async updateEnvironmentVariable(
environmentVariableId: string,
data: DeepPartial<EnvironmentVariable>
): Promise<boolean> {
@ -342,7 +363,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async deleteEnvironmentVariable (
async deleteEnvironmentVariable(
environmentVariableId: string
): Promise<boolean> {
const environmentVariableRepository =
@ -358,7 +379,7 @@ export class Database {
}
}
async getProjectMemberById (projectMemberId: string): Promise<ProjectMember> {
async getProjectMemberById(projectMemberId: string): Promise<ProjectMember> {
const projectMemberRepository =
this.dataSource.getRepository(ProjectMember);
@ -381,7 +402,7 @@ export class Database {
return projectMemberWithProject[0];
}
async getProjectsBySearchText (
async getProjectsBySearchText(
userId: string,
searchText: string
): Promise<Project[]> {
@ -403,14 +424,14 @@ export class Database {
return projects;
}
async updateDeploymentById (
async updateDeploymentById(
deploymentId: string,
data: DeepPartial<Deployment>
): Promise<boolean> {
return this.updateDeployment({ id: deploymentId }, data);
}
async updateDeployment (
async updateDeployment(
criteria: FindOptionsWhere<Deployment>,
data: DeepPartial<Deployment>
): Promise<boolean> {
@ -420,7 +441,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async updateDeploymentsByProjectIds (
async updateDeploymentsByProjectIds(
projectIds: string[],
data: DeepPartial<Deployment>
): Promise<boolean> {
@ -436,7 +457,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async deleteDeploymentById (deploymentId: string): Promise<boolean> {
async deleteDeploymentById(deploymentId: string): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOneOrFail({
where: {
@ -449,7 +470,7 @@ export class Database {
return Boolean(deleteResult);
}
async addProject (user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
async addProject(user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project);
// TODO: Check if organization exists
@ -465,12 +486,16 @@ export class Database {
id: organizationId
});
newProject.subDomain = `${newProject.name}.${this.projectDomain}`;
return projectRepository.save(newProject);
}
async updateProjectById (
async saveProject(project: Project): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project);
return projectRepository.save(project);
}
async updateProjectById(
projectId: string,
data: DeepPartial<Project>
): Promise<boolean> {
@ -483,7 +508,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async deleteProjectById (projectId: string): Promise<boolean> {
async deleteProjectById(projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository.findOneOrFail({
where: {
@ -499,7 +524,7 @@ export class Database {
return Boolean(deleteResult);
}
async deleteDomainById (domainId: string): Promise<boolean> {
async deleteDomainById(domainId: string): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain);
const deleteResult = await domainRepository.softDelete({ id: domainId });
@ -511,21 +536,21 @@ export class Database {
}
}
async addDomain (data: DeepPartial<Domain>): Promise<Domain> {
async addDomain(data: DeepPartial<Domain>): Promise<Domain> {
const domainRepository = this.dataSource.getRepository(Domain);
const newDomain = await domainRepository.save(data);
return newDomain;
}
async getDomain (options: FindOneOptions<Domain>): Promise<Domain | null> {
async getDomain(options: FindOneOptions<Domain>): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain);
const domain = await domainRepository.findOne(options);
return domain;
}
async updateDomainById (
async updateDomainById(
domainId: string,
data: DeepPartial<Domain>
): Promise<boolean> {
@ -535,7 +560,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async getDomainsByProjectId (
async getDomainsByProjectId(
projectId: string,
filter?: FindOptionsWhere<Domain>
): Promise<Domain[]> {
@ -555,4 +580,24 @@ export class Database {
return domains;
}
async addDeployer(data: DeepPartial<Deployer>): Promise<Deployer> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const newDomain = await deployerRepository.save(data);
return newDomain;
}
async getDeployers(): Promise<Deployer[]> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const deployers = await deployerRepository.find();
return deployers;
}
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const deployer = await deployerRepository.findOne({ where: { deployerLrn } });
return deployer;
}
}

View File

@ -0,0 +1,26 @@
import { Entity, PrimaryColumn, Column, ManyToMany } from 'typeorm';
import { Project } from './Project';
@Entity()
export class Deployer {
@PrimaryColumn('varchar')
deployerLrn!: string;
@Column('varchar')
deployerId!: string;
@Column('varchar')
deployerApiUrl!: string;
@Column('varchar')
baseDomain!: string;
@Column('varchar', { nullable: true })
minimumPayment!: string | null;
@Column('varchar', { nullable: true })
paymentAddress!: string | null;
@ManyToMany(() => Project, (project) => project.deployers)
projects!: Project[];
}

View File

@ -13,6 +13,7 @@ import {
import { Project } from './Project';
import { Domain } from './Domain';
import { User } from './User';
import { Deployer } from './Deployer';
import { AppDeploymentRecordAttributes, AppDeploymentRemovalRecordAttributes } from '../types';
export enum Environment {
@ -33,21 +34,21 @@ export interface ApplicationDeploymentRequest {
version: string;
name: string;
application: string;
lrn?: string;
auction?: string;
config: string;
meta: string;
payment?: string;
}
export interface ApplicationDeploymentRemovalRequest {
type: string;
version: string;
deployment: string;
auction?: string;
payment?: string;
}
export interface ApplicationDeploymentRemovalRequest {
type: string;
version: string;
deployment: string;
}
export interface ApplicationRecord {
type: string;
@ -112,19 +113,23 @@ export class Deployment {
@Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null;
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRequestId!: string | null;
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null;
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRecordId!: string | null;
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null;
@ManyToOne(() => Deployer)
@JoinColumn({ name: 'deployerLrn' })
deployer!: Deployer;
@Column({
enum: Environment
})
@ -147,7 +152,7 @@ export class Deployment {
@UpdateDateColumn()
updatedAt!: Date;
@DeleteDateColumn()
deletedAt!: Date | null;
}

View File

@ -7,13 +7,16 @@ import {
ManyToOne,
JoinColumn,
OneToMany,
DeleteDateColumn
DeleteDateColumn,
JoinTable,
ManyToMany
} from 'typeorm';
import { User } from './User';
import { Organization } from './Organization';
import { ProjectMember } from './ProjectMember';
import { Deployment } from './Deployment';
import { Deployer } from './Deployer';
@Entity()
export class Project {
@ -46,6 +49,20 @@ export class Project {
@Column('text', { default: '' })
description!: string;
@Column('varchar', { nullable: true })
auctionId!: string | null;
// Tx hash for sending coins from snowball to deployer
@Column('varchar', { nullable: true })
txHash!: string | null;
@ManyToMany(() => Deployer, (deployer) => (deployer.projects))
@JoinTable()
deployers!: Deployer[]
@Column('boolean', { default: false, nullable: true })
fundsReleased!: boolean;
// TODO: Compute template & framework in import repository
@Column('varchar', { nullable: true })
template!: string | null;
@ -53,6 +70,10 @@ export class Project {
@Column('varchar', { nullable: true })
framework!: string | null;
// Address of the user who created the project i.e. requested deployments
@Column('varchar')
paymentAddress!: string;
@Column({
type: 'simple-array'
})
@ -61,9 +82,6 @@ export class Project {
@Column('varchar')
icon!: string;
@Column('varchar')
subDomain!: string;
@CreateDateColumn()
createdAt!: Date;

View File

@ -17,7 +17,7 @@ const log = debug('snowball:server');
const OAUTH_CLIENT_TYPE = 'oauth-app';
export const main = async (): Promise<void> => {
const { server, database, gitHub, registryConfig, misc } = await getConfig();
const { server, database, gitHub, registryConfig } = await getConfig();
const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE,
@ -25,7 +25,7 @@ export const main = async (): Promise<void> => {
clientSecret: gitHub.oAuth.clientSecret,
});
const db = new Database(database, misc);
const db = new Database(database);
await db.init();
const registry = new Registry(registryConfig);

View File

@ -1,9 +1,12 @@
import debug from 'debug';
import assert from 'assert';
import { inc as semverInc } from 'semver';
import debug from 'debug';
import { DateTime } from 'luxon';
import { Octokit } from 'octokit';
import { inc as semverInc } from 'semver';
import { DeepPartial } from 'typeorm';
import { Registry as LaconicRegistry } from '@snowballtools/laconic-sdk';
import { Account, DEFAULT_GAS_ESTIMATION_MULTIPLIER, Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk';
import { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate';
import { RegistryConfig } from './config';
import {
@ -12,49 +15,53 @@ import {
ApplicationDeploymentRequest,
ApplicationDeploymentRemovalRequest
} from './entity/Deployment';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, PackageJSON } from './types';
import { sleep } from './utils';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, AuctionParams, DeployerRecord } from './types';
import { getConfig, getRepoDetails, registryTransactionWithRetry, sleep } from './utils';
const log = debug('snowball:registry');
const APP_RECORD_TYPE = 'ApplicationRecord';
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction';
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest';
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = 'ApplicationDeploymentRemovalRequest';
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord';
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord';
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer'
const SLEEP_DURATION = 1000;
// TODO: Move registry code to laconic-sdk/watcher-ts
// TODO: Move registry code to registry-sdk/watcher-ts
export class Registry {
private registry: LaconicRegistry;
private registryConfig: RegistryConfig;
constructor (registryConfig: RegistryConfig) {
constructor(registryConfig: RegistryConfig) {
this.registryConfig = registryConfig;
const gasPrice = getGasPrice(registryConfig.fee.gasPrice);
this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
registryConfig.chainId
{ chainId: registryConfig.chainId, gasPrice }
);
}
async createApplicationRecord ({
appName,
packageJSON,
async createApplicationRecord({
octokit,
repository,
commitHash,
appType,
repoUrl
}: {
appName: string;
packageJSON: PackageJSON;
octokit: Octokit
repository: string;
commitHash: string;
appType: string;
repoUrl: string;
}): Promise<{
applicationRecordId: string;
applicationRecordData: ApplicationRecord;
}> {
// Use laconic-sdk to publish record
const { repo, repoUrl, packageJSON } = await getRepoDetails(octokit, repository, commitHash)
// Use registry-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
const records = await this.registry.queryRecords(
@ -87,7 +94,7 @@ export class Registry {
repository_ref: commitHash,
repository: [repoUrl],
app_type: appType,
name: appName,
name: repo,
...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }),
@ -100,68 +107,158 @@ export class Registry {
...(packageJSON.version && { app_version: packageJSON.version })
};
const result = await this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationRecord,
bondId: this.registryConfig.bondId
},
'',
this.registryConfig.fee
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationRecord,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
);
log(`Published application record ${result.id}`);
log('Application record data:', applicationRecord);
// TODO: Discuss computation of CRN
const crn = this.getCrn(appName);
log(`Setting name: ${crn} for record ID: ${result.data.id}`);
// TODO: Discuss computation of LRN
const lrn = this.getLrn(repo);
log(`Setting name: ${lrn} for record ID: ${result.id}`);
await sleep(SLEEP_DURATION);
await this.registry.setName(
{ cid: result.data.id, crn },
this.registryConfig.privateKey,
this.registryConfig.fee
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn
},
this.registryConfig.privateKey,
fee
)
);
await sleep(SLEEP_DURATION);
await this.registry.setName(
{ cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` },
this.registryConfig.privateKey,
this.registryConfig.fee
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.app_version}`
},
this.registryConfig.privateKey,
fee
)
);
await sleep(SLEEP_DURATION);
await this.registry.setName(
{
cid: result.data.id,
crn: `${crn}@${applicationRecord.repository_ref}`
},
this.registryConfig.privateKey,
this.registryConfig.fee
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.repository_ref}`
},
this.registryConfig.privateKey,
fee
)
);
return {
applicationRecordId: result.data.id,
applicationRecordId: result.id,
applicationRecordData: applicationRecord
};
}
async createApplicationDeploymentRequest (data: {
async createApplicationDeploymentAuction(
appName: string,
octokit: Octokit,
auctionParams: AuctionParams,
data: DeepPartial<Deployment>,
): Promise<{
applicationDeploymentAuctionId: string;
}> {
assert(data.project?.repository, 'Project repository not found');
await this.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!,
});
const lrn = this.getLrn(appName);
const config = await getConfig();
const auctionConfig = config.auction;
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const auctionResult = await registryTransactionWithRetry(() =>
this.registry.createProviderAuction(
{
commitFee: auctionConfig.commitFee,
commitsDuration: auctionConfig.commitsDuration,
revealFee: auctionConfig.revealFee,
revealsDuration: auctionConfig.revealsDuration,
denom: auctionConfig.denom,
maxPrice: auctionParams.maxPrice,
numProviders: auctionParams.numProviders,
},
this.registryConfig.privateKey,
fee
)
);
if (!auctionResult.auction) {
throw new Error('Error creating auction');
}
// Create record of type applicationDeploymentAuction and publish
const applicationDeploymentAuction = {
application: lrn,
auction: auctionResult.auction.id,
type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE,
};
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentAuction,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
);
log(`Application deployment auction created: ${auctionResult.auction.id}`);
log(`Application deployment auction record published: ${result.id}`);
log('Application deployment auction data:', applicationDeploymentAuction);
return {
applicationDeploymentAuctionId: auctionResult.auction.id,
};
}
async createApplicationDeploymentRequest(data: {
deployment: Deployment,
appName: string,
repository: string,
auctionId?: string | null,
lrn: string,
environmentVariables: { [key: string]: string },
dns: string,
payment?: string | null
}): Promise<{
applicationDeploymentRequestId: string;
applicationDeploymentRequestData: ApplicationDeploymentRequest;
}> {
const crn = this.getCrn(data.appName);
const records = await this.registry.resolveNames([crn]);
const lrn = this.getLrn(data.appName);
const records = await this.registry.resolveNames([lrn]);
const applicationRecord = records[0];
if (!applicationRecord) {
throw new Error(`No record found for ${crn}`);
throw new Error(`No record found for ${lrn}`);
}
// Create record of type ApplicationDeploymentRequest and publish
@ -169,12 +266,9 @@ export class Registry {
type: APP_DEPLOYMENT_REQUEST_TYPE,
version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${crn}@${applicationRecord.attributes.app_version}`,
application: `${lrn}@${applicationRecord.attributes.app_version}`,
dns: data.dns,
// TODO: Not set in test-progressive-web-app CI
// deployment: '$CERC_REGISTRY_DEPLOYMENT_CRN',
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
config: JSON.stringify({
env: data.environmentVariables
@ -185,32 +279,87 @@ export class Registry {
)}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
})
}),
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment }),
};
await sleep(SLEEP_DURATION);
const result = await this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentRequest,
bondId: this.registryConfig.bondId
},
'',
this.registryConfig.fee
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentRequest,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
);
log(`Application deployment request record published: ${result.data.id}`);
log(`Application deployment request record published: ${result.id}`);
log('Application deployment request data:', applicationDeploymentRequest);
return {
applicationDeploymentRequestId: result.data.id,
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData: applicationDeploymentRequest
};
}
async getAuctionWinningDeployerRecords(
auctionId: string
): Promise<DeployerRecord[]> {
const records = await this.registry.getAuctionsByIds([auctionId]);
const auctionResult = records[0];
let deployerRecords = [];
const { winnerAddresses } = auctionResult;
for (const auctionWinner of winnerAddresses) {
const records = await this.getDeployerRecordsByFilter({
paymentAddress: auctionWinner,
});
const newRecords = records.filter(record => {
return record.names !== null && record.names.length > 0;
});
for (const record of newRecords) {
if (record.id) {
deployerRecords.push(record);
break;
}
}
}
return deployerRecords;
}
async releaseDeployerFunds(
auctionId: string
): Promise<any> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const auction = await registryTransactionWithRetry(() =>
this.registry.releaseFunds(
{
auctionId
},
this.registryConfig.privateKey,
fee
)
);
return auction;
}
/**
* Fetch ApplicationDeploymentRecords for deployments
*/
async getDeploymentRecords (
async getDeploymentRecords(
deployments: Deployment[]
): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
@ -222,20 +371,33 @@ export class Registry {
true
);
// Filter records with ApplicationRecord ID and Deployment specific URL
// Filter records with ApplicationDeploymentRequestId ID and Deployment specific URL
return records.filter((record: AppDeploymentRecord) =>
deployments.some(
(deployment) =>
deployment.applicationRecordId === record.attributes.application &&
deployment.applicationDeploymentRequestId === record.attributes.request &&
record.attributes.url.includes(deployment.id)
)
);
}
/**
* Fetch WebappDeployer Records by filter
*/
async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise<DeployerRecord[]> {
return this.registry.queryRecords(
{
type: WEBAPP_DEPLOYER_RECORD_TYPE,
...filter
},
true
);
}
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter (filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
@ -248,7 +410,7 @@ export class Registry {
/**
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords (
async getDeploymentRemovalRecords(
deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
@ -269,8 +431,11 @@ export class Registry {
);
}
async createApplicationDeploymentRemovalRequest (data: {
async createApplicationDeploymentRemovalRequest(data: {
deploymentId: string;
deployerLrn: string;
auctionId?: string | null;
payment?: string | null;
}): Promise<{
applicationDeploymentRemovalRequestId: string;
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest;
@ -278,30 +443,93 @@ export class Registry {
const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0',
deployment: data.deploymentId
deployment: data.deploymentId,
deployer: data.deployerLrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment }),
};
const result = await this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentRemovalRequest,
bondId: this.registryConfig.bondId
},
'',
this.registryConfig.fee
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentRemovalRequest,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
);
log(`Application deployment removal request record published: ${result.data.id}`);
log(`Application deployment removal request record published: ${result.id}`);
log('Application deployment removal request data:', applicationDeploymentRemovalRequest);
return {
applicationDeploymentRemovalRequestId: result.data.id,
applicationDeploymentRemovalRequestId: result.id,
applicationDeploymentRemovalRequestData: applicationDeploymentRemovalRequest
};
}
getCrn (appName: string): string {
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> {
if (auctionIds.length === 0) {
return [];
}
const auctions = await this.registry.getAuctionsByIds(auctionIds);
const completedAuctions = auctions
.filter((auction: { id: string, status: string }) => auction.status === 'completed')
.map((auction: { id: string, status: string }) => auction.id);
return completedAuctions;
}
async getRecordsByName(name: string): Promise<any> {
return this.registry.resolveNames([name]);
}
async getAuctionData(auctionId: string): Promise<any> {
return this.registry.getAuctionsByIds([auctionId]);
}
async sendTokensToAccount(receiverAddress: string, amount: string): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const account = await this.getAccount();
const laconicClient = await this.registry.getLaconicClient(account);
const txResponse: DeliverTxResponse =
await registryTransactionWithRetry(() =>
laconicClient.sendTokens(account.address, receiverAddress,
[
{
denom: 'alnt',
amount
}
],
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER)
);
return txResponse;
}
async getAccount(): Promise<Account> {
const account = new Account(Buffer.from(this.registryConfig.privateKey, 'hex'));
await account.init();
return account;
}
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
const account = await this.getAccount();
const laconicClient = await this.registry.getLaconicClient(account);
const txResponse: IndexedTx | null = await laconicClient.getTx(txHash);
return txResponse;
}
getLrn(appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist");
return `crn://${this.registryConfig.authority}/applications/${appName}`;
return `lrn://${this.registryConfig.authority}/applications/${appName}`;
}
}

View File

@ -6,6 +6,7 @@ import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain';
import { Project } from './entity/Project';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { AddProjectFromTemplateInput, AuctionParams, EnvironmentVariables } from './types';
const log = debug('snowball:resolver');
@ -21,8 +22,8 @@ export const createResolvers = async (service: Service): Promise<any> => {
return service.getOrganizationsByUserId(context.user);
},
project: async (_: any, { projectId }: { projectId: string }) => {
return service.getProjectById(projectId);
project: async (_: any, { projectId }: { projectId: string }, context: any) => {
return service.getProjectById(context.user, projectId);
},
projectsInOrganization: async (
@ -68,6 +69,32 @@ export const createResolvers = async (service: Service): Promise<any> => {
) => {
return service.getDomainsByProjectId(projectId, filter);
},
getAuctionData: async (
_: any,
{ auctionId }: { auctionId: string },
) => {
return service.getAuctionData(auctionId);
},
deployers: async (_: any, __: any, context: any) => {
return service.getDeployers();
},
address: async (_: any, __: any, context: any) => {
return service.getAddress();
},
verifyTx: async (
_: any,
{
txHash,
amount,
senderAddress,
}: { txHash: string; amount: string; senderAddress: string },
) => {
return service.verifyTx(txHash, amount, senderAddress);
},
},
// TODO: Return error in GQL response
@ -197,16 +224,64 @@ export const createResolvers = async (service: Service): Promise<any> => {
}
},
addProjectFromTemplate: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string;
data: AddProjectFromTemplateInput;
lrn: string;
auctionParams: AuctionParams;
environmentVariables: EnvironmentVariables[];
},
context: any,
) => {
try {
return await service.addProjectFromTemplate(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
);
} catch (err) {
log(err);
throw err;
}
},
addProject: async (
_: any,
{
organizationSlug,
data,
}: { organizationSlug: string; data: DeepPartial<Project> },
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string;
data: DeepPartial<Project>;
lrn: string;
auctionParams: AuctionParams,
environmentVariables: EnvironmentVariables[];
},
context: any,
) => {
try {
return await service.addProject(context.user, organizationSlug, data);
return await service.addProject(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
);
} catch (err) {
log(err);
throw err;

View File

@ -27,7 +27,7 @@ router.post('/register', async (req, res) => {
userEmail: email,
userName: email.split('@')[0],
});
req.session.userId = user.id;
req.session.address = user.id;
res.sendStatus(200);
});
@ -39,7 +39,7 @@ router.post('/authenticate', async (req, res) => {
signedWhoamiRequest,
);
if (user) {
req.session.userId = user.id;
req.session.address = user.id;
res.sendStatus(200);
} else {
res.sendStatus(401);
@ -47,11 +47,10 @@ router.post('/authenticate', async (req, res) => {
});
//
// Lit
// SIWE Auth
//
router.post('/validate', async (req, res) => {
const { message, signature, action } = req.body;
const { message, signature } = req.body;
const { success, data } = await new SiweMessage(message).verify({
signature,
});
@ -62,23 +61,20 @@ router.post('/validate', async (req, res) => {
const service: Service = req.app.get('service');
const user = await service.getUserByEthAddress(data.address);
if (action === 'signup') {
if (user) {
return res.send({ success: false, error: 'user_already_exists' });
}
if (!user) {
const newUser = await service.createUser({
ethAddress: data.address,
email: '',
name: '',
email: `${data.address}@example.com`,
subOrgId: '',
turnkeyWalletId: '',
});
req.session.userId = newUser.id;
} else if (action === 'login') {
if (!user) {
return res.send({ success: false, error: 'user_not_found' });
}
req.session.userId = user.id;
// SIWESession from the web3modal library requires both address and chain ID
req.session.address = newUser.id;
req.session.chainId = data.chainId;
} else {
req.session.address = user.id;
req.session.chainId = data.chainId;
}
res.send({ success });
@ -88,9 +84,10 @@ router.post('/validate', async (req, res) => {
// General
//
router.get('/session', (req, res) => {
if (req.session.userId) {
if (req.session.address && req.session.chainId) {
res.send({
userId: req.session.userId,
address: req.session.address,
chainId: req.session.chainId
});
} else {
res.status(401).send({ error: 'Unauthorized: No active session' });
@ -98,9 +95,12 @@ router.get('/session', (req, res) => {
});
router.post('/logout', (req, res) => {
// This is how you clear cookie-session
(req as any).session = null;
res.send({ success: true });
req.session.destroy((err) => {
if (err) {
return res.send({ success: false });
}
res.send({ success: true });
});
});
export default router;

View File

@ -3,7 +3,7 @@ import { Router } from 'express';
const router = Router();
router.get('/version', async (req, res) => {
return res.send({ version: '0.0.7' });
return res.send({ version: '0.0.9' });
});
export default router;

View File

@ -22,6 +22,13 @@ enum DeploymentStatus {
Deleting
}
enum AuctionStatus {
completed
reveal
commit
expired
}
enum DomainStatus {
Live
Pending
@ -65,8 +72,13 @@ type Project {
repository: String!
prodBranch: String!
description: String
deployers: [Deployer!]
auctionId: String
fundsReleased: Boolean
template: String
framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!]
members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!]
@ -74,7 +86,7 @@ type Project {
updatedAt: String!
organization: Organization!
icon: String
subDomain: String
baseDomains: [String!]
}
type ProjectMember {
@ -94,7 +106,10 @@ type Deployment {
commitMessage: String!
url: String
environment: Environment!
deployer: Deployer
applicationDeploymentRequestId: String
isCurrent: Boolean!
baseDomain: String
status: DeploymentStatus!
createdAt: String!
updatedAt: String!
@ -120,6 +135,17 @@ type EnvironmentVariable {
updatedAt: String!
}
type Deployer {
deployerLrn: String!
deployerId: String!
deployerApiUrl: String!
minimumPayment: String
paymentAddress: String
createdAt: String!
updatedAt: String!
baseDomain: String
}
type AuthResult {
token: String!
}
@ -130,11 +156,23 @@ input AddEnvironmentVariableInput {
value: String!
}
input AddProjectFromTemplateInput {
templateOwner: String!
templateRepo: String!
owner: String!
name: String!
isPrivate: Boolean!
paymentAddress: String!
txHash: String!
}
input AddProjectInput {
name: String!
repository: String!
prodBranch: String!
template: String
paymentAddress: String!
txHash: String!
}
input UpdateProjectInput {
@ -174,6 +212,48 @@ input FilterDomainsInput {
status: DomainStatus
}
type Fee {
type: String!
quantity: String!
}
type Bid {
auctionId: String!
bidderAddress: String!
status: String!
commitHash: String!
commitTime: String
commitFee: Fee
revealTime: String
revealFee: Fee
bidAmount: Fee
}
type Auction {
id: String!
kind: String!
status: String!
ownerAddress: String!
createTime: String!
commitsEndTime: String!
revealsEndTime: String!
commitFee: Fee!
revealFee: Fee!
minimumBid: Fee
winnerAddresses: [String!]!
winnerBids: [Fee!]
winnerPrice: Fee
maxPrice: Fee
numProviders: Int!
fundsReleased: Boolean!
bids: [Bid!]!
}
input AuctionParams {
maxPrice: String,
numProviders: Int,
}
type Query {
user: User!
organizations: [Organization!]
@ -184,7 +264,11 @@ type Query {
environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!]
getAuctionData(auctionId: String!): Auction!
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
}
type Mutation {
@ -204,7 +288,20 @@ type Mutation {
): Boolean!
removeEnvironmentVariable(environmentVariableId: String!): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean!
addProject(organizationSlug: String!, data: AddProjectInput): Project!
addProjectFromTemplate(
organizationSlug: String!
data: AddProjectFromTemplateInput
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
addProject(
organizationSlug: String!
data: AddProjectInput!
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean!

View File

@ -8,7 +8,7 @@ import {
ApolloServerPluginLandingPageLocalDefault,
AuthenticationError,
} from 'apollo-server-core';
import cookieSession from 'cookie-session';
import session from 'express-session';
import { TypeSource } from '@graphql-tools/utils';
import { makeExecutableSchema } from '@graphql-tools/schema';
@ -22,9 +22,13 @@ import { Service } from './service';
const log = debug('snowball:server');
// Set cookie expiration to 1 month in milliseconds
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
declare module 'express-session' {
interface SessionData {
userId: string;
address: string;
chainId: number;
}
}
@ -54,14 +58,13 @@ export const createAndStartServer = async (
context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { userId } = req.session;
const { address } = req.session;
if (!userId) {
if (!address) {
throw new AuthenticationError('Unauthorized: No active session');
}
const user = await service.getUser(userId);
const user = await service.getUser(address);
return { user };
},
plugins: [
@ -80,20 +83,25 @@ export const createAndStartServer = async (
}),
);
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
maxAge: COOKIE_MAX_AGE,
domain: domain || undefined,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax',
}
};
if (trustProxy) {
// trust first proxy
app.set('trust proxy', 1);
}
app.use(
cookieSession({
secret: secret,
secure: new URL(appOriginUrl).protocol === 'https:',
// 23 hours (less than 24 hours to avoid sessionSigs expiration issues)
maxAge: 23 * 60 * 60 * 1000,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax',
domain: domain || undefined,
}),
session(sessionOptions)
);
server.applyMiddleware({

View File

@ -1,12 +1,12 @@
import assert from 'assert';
import debug from 'debug';
import { DeepPartial, FindOptionsWhere } from 'typeorm';
import { DeepPartial, FindOptionsWhere, IsNull, Not } from 'typeorm';
import { Octokit, RequestError } from 'octokit';
import { OAuthApp } from '@octokit/oauth-app';
import { Database } from './database';
import { Deployment, DeploymentStatus, Environment } from './entity/Deployment';
import { ApplicationRecord, Deployment, DeploymentStatus, Environment } from './entity/Deployment';
import { Domain } from './entity/Domain';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Organization } from './entity/Organization';
@ -14,14 +14,19 @@ import { Project } from './entity/Project';
import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User';
import { Registry } from './registry';
import { Deployer } from './entity/Deployer';
import { GitHubConfig, RegistryConfig } from './config';
import {
AddProjectFromTemplateInput,
AppDeploymentRecord,
AppDeploymentRemovalRecord,
AuctionParams,
DeployerRecord,
EnvironmentVariables,
GitPushEventPayload,
PackageJSON,
} from './types';
import { Role } from './entity/UserOrganization';
import { getRepoDetails } from './utils';
const log = debug('snowball:service');
@ -38,15 +43,16 @@ interface Config {
export class Service {
private db: Database;
private oauthApp: OAuthApp;
private registry: Registry;
private laconicRegistry: Registry;
private config: Config;
private deployRecordCheckTimeout?: NodeJS.Timeout;
private auctionStatusCheckTimeout?: NodeJS.Timeout;
constructor(config: Config, db: Database, app: OAuthApp, registry: Registry) {
this.db = db;
this.oauthApp = app;
this.registry = registry;
this.laconicRegistry = registry;
this.config = config;
this.init();
}
@ -59,6 +65,8 @@ export class Service {
this.checkDeployRecordsAndUpdate();
// Start check for ApplicationDeploymentRemovalRecords asynchronously
this.checkDeploymentRemovalRecordsAndUpdate();
// Start check for Deployment Auctions asynchronously
this.checkAuctionStatus();
}
/**
@ -66,6 +74,7 @@ export class Service {
*/
destroy(): void {
clearTimeout(this.deployRecordCheckTimeout);
clearTimeout(this.auctionStatusCheckTimeout);
}
/**
@ -107,7 +116,7 @@ export class Service {
}
// Fetch ApplicationDeploymentRecord for deployments
const records = await this.registry.getDeploymentRecords(deployments);
const records = await this.laconicRegistry.getDeploymentRecords(deployments);
log(`Found ${records.length} ApplicationDeploymentRecords`);
// Update deployments for which ApplicationDeploymentRecords were returned
@ -140,7 +149,7 @@ export class Service {
// Fetch ApplicationDeploymentRemovalRecords for deployments
const records =
await this.registry.getDeploymentRemovalRecords(deployments);
await this.laconicRegistry.getDeploymentRemovalRecords(deployments);
log(`Found ${records.length} ApplicationDeploymentRemovalRecords`);
// Update deployments for which ApplicationDeploymentRemovalRecords were returned
@ -156,41 +165,28 @@ export class Service {
/**
* Update deployments with ApplicationDeploymentRecord data
* Deployments that are completed but not updated in DB
*/
async updateDeploymentsWithRecordData(
records: AppDeploymentRecord[],
): Promise<void> {
// Get deployments for ApplicationDeploymentRecords
// Fetch the deployments to be updated using deployment requestId
const deployments = await this.db.getDeployments({
where: records.map((record) => ({
applicationRecordId: record.attributes.application,
applicationDeploymentRequestId: record.attributes.request,
})),
relations: {
deployer: true,
project: true,
},
order: {
createdAt: 'DESC',
},
});
// Get project IDs of deployments that are in production environment
const productionDeploymentProjectIds = deployments.reduce(
(acc, deployment): Set<string> => {
if (deployment.environment === Environment.Production) {
acc.add(deployment.projectId);
}
return acc;
},
new Set<string>(),
);
// Set old deployments isCurrent to false
await this.db.updateDeploymentsByProjectIds(
Array.from(productionDeploymentProjectIds),
{ isCurrent: false },
);
const recordToDeploymentsMap = deployments.reduce(
(acc: { [key: string]: Deployment }, deployment) => {
acc[deployment.applicationRecordId] = deployment;
acc[deployment.applicationDeploymentRequestId!] = deployment;
return acc;
},
{},
@ -198,22 +194,57 @@ export class Service {
// Update deployment data for ApplicationDeploymentRecords
const deploymentUpdatePromises = records.map(async (record) => {
const deployment = recordToDeploymentsMap[record.attributes.application];
const deployment = recordToDeploymentsMap[record.attributes.request];
await this.db.updateDeploymentById(deployment.id, {
applicationDeploymentRecordId: record.id,
applicationDeploymentRecordData: record.attributes,
url: record.attributes.url,
status: DeploymentStatus.Ready,
isCurrent: deployment.environment === Environment.Production,
});
if (!deployment.project) {
log(`Project ${deployment.projectId} not found`);
return;
} else {
deployment.applicationDeploymentRecordId = record.id;
deployment.applicationDeploymentRecordData = record.attributes;
deployment.url = record.attributes.url;
deployment.status = DeploymentStatus.Ready;
deployment.isCurrent = deployment.environment === Environment.Production;
log(
`Updated deployment ${deployment.id} with URL ${record.attributes.url}`,
);
await this.db.updateDeploymentById(deployment.id, deployment);
// Release deployer funds on successful deployment
if (!deployment.project.fundsReleased) {
const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId);
// Return remaining amount to owner
await this.returnUserFundsByProjectId(deployment.projectId, true);
await this.db.updateProjectById(deployment.projectId, {
fundsReleased,
});
}
log(
`Updated deployment ${deployment.id} with URL ${record.attributes.url}`,
);
}
});
await Promise.all(deploymentUpdatePromises);
// Get deployments that are in production environment
const prodDeployments = Object.values(recordToDeploymentsMap).filter(deployment => deployment.isCurrent);
// Set the isCurrent state to false for the old deployments
for (const deployment of prodDeployments) {
const projectDeployments = await this.db.getDeploymentsByProjectId(deployment.projectId);
const oldDeployments = projectDeployments
.filter(projectDeployment => projectDeployment.deployer.deployerLrn === deployment.deployer.deployerLrn && projectDeployment.id !== deployment.id);
for (const oldDeployment of oldDeployments) {
await this.db.updateDeployment(
{ id: oldDeployment.id },
{ isCurrent: false }
);
}
}
await Promise.all(deploymentUpdatePromises);
}
/**
@ -261,6 +292,45 @@ export class Service {
await Promise.all(deploymentUpdatePromises);
}
/**
* Checks the status for all ongoing auctions
* Calls the createDeploymentFromAuction method for deployments with completed auctions
*/
async checkAuctionStatus(): Promise<void> {
const projects = await this.db.allProjectsWithoutDeployments();
const validAuctionIds = projects.map((project) => project.auctionId)
.filter((id): id is string => Boolean(id));
const completedAuctionIds = await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds);
const projectsToBedeployed = projects.filter((project) =>
completedAuctionIds.includes(project.auctionId!)
);
for (const project of projectsToBedeployed) {
const deployerRecords = await this.laconicRegistry.getAuctionWinningDeployerRecords(project!.auctionId!);
if (!deployerRecords) {
log(`No winning deployer for auction ${project!.auctionId}`);
// Return all funds to the owner
await this.returnUserFundsByProjectId(project.id, false)
} else {
const deployers = await this.saveDeployersByDeployerRecords(deployerRecords);
for (const deployer of deployers) {
log(`Creating deployment for deployer ${deployer.deployerLrn}`);
await this.createDeploymentFromAuction(project, deployer);
// Update project with deployer
await this.updateProjectWithDeployer(project.id, deployer);
}
}
}
this.auctionStatusCheckTimeout = setTimeout(() => {
this.checkAuctionStatus();
}, this.config.registryConfig.checkAuctionStatusDelay);
}
async getUser(userId: string): Promise<User | null> {
return this.db.getUser({
where: {
@ -294,7 +364,7 @@ export class Service {
}
async createUser(params: {
name: string;
name?: string;
email: string;
subOrgId: string;
ethAddress: string;
@ -337,8 +407,13 @@ export class Service {
return dbOrganizations;
}
async getProjectById(projectId: string): Promise<Project | null> {
async getProjectById(user: User, projectId: string): Promise<Project | null> {
const dbProject = await this.db.getProjectById(projectId);
if (dbProject && dbProject.owner.id !== user.id) {
return null;
}
return dbProject;
}
@ -518,6 +593,7 @@ export class Service {
domain: prodBranchDomains[0],
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployer: oldDeployment.deployer
});
return newDeployment;
@ -527,44 +603,20 @@ export class Service {
userId: string,
octokit: Octokit,
data: DeepPartial<Deployment>,
deployerLrn?: string
): Promise<Deployment> {
assert(data.project?.repository, 'Project repository not found');
log(
`Creating deployment in project ${data.project.name} from branch ${data.branch}`,
);
const [owner, repo] = data.project.repository.split('/');
const { data: packageJSONData } = await octokit.rest.repos.getContent({
owner,
repo,
path: 'package.json',
ref: data.commitHash,
});
if (!packageJSONData) {
throw new Error('Package.json file not found');
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file');
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content));
assert(packageJSON.name, "name field doesn't exist in package.json");
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo,
})
).data.html_url;
// TODO: Set environment variables for each deployment (environment variables can`t be set in application record)
const { applicationRecordId, applicationRecordData } =
await this.registry.createApplicationRecord({
appName: repo,
packageJSON,
await this.laconicRegistry.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!,
repoUrl,
});
// Update previous deployment with prod branch domain
@ -580,6 +632,138 @@ export class Service {
);
}
let deployer;
if (deployerLrn) {
deployer = await this.db.getDeployerByLRN(deployerLrn);
} else {
deployer = data.deployer;
}
const newDeployment = await this.createDeploymentFromData(userId, data, deployer!.deployerLrn!, applicationRecordId, applicationRecordData);
const { repo, repoUrl } = await getRepoDetails(octokit, data.project.repository, data.commitHash);
const environmentVariablesObj = await this.getEnvVariables(data.project!.id!);
// To set project DNS
if (data.environment === Environment.Production) {
// On deleting deployment later, project DNS deployment is also deleted
// So publish project DNS deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`,
lrn: deployer!.deployerLrn!,
payment: data.project.txHash,
auctionId: data.project.auctionId
});
}
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
lrn: deployer!.deployerLrn!,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`,
payment: data.project.txHash,
auctionId: data.project.auctionId
});
await this.db.updateDeploymentById(newDeployment.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
});
return newDeployment;
}
async createDeploymentFromAuction(
project: DeepPartial<Project>,
deployer: Deployer
): Promise<Deployment> {
const octokit = await this.getOctokit(project.ownerId!);
const [owner, repo] = project.repository!.split('/');
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo,
})
).data.html_url;
const {
data: [latestCommit],
} = await octokit.rest.repos.listCommits({
owner,
repo,
sha: project.prodBranch,
per_page: 1,
});
const lrn = this.laconicRegistry.getLrn(repo);
const [record] = await this.laconicRegistry.getRecordsByName(lrn);
const applicationRecordId = record.id;
const applicationRecordData = record.attributes;
const deployerLrn = deployer!.deployerLrn
// Create deployment with prod branch and latest commit
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
};
const newDeployment = await this.createDeploymentFromData(project.ownerId!, deploymentData, deployerLrn, applicationRecordId, applicationRecordData);
const environmentVariablesObj = await this.getEnvVariables(project!.id!);
// To set project DNS
if (deploymentData.environment === Environment.Production) {
// On deleting deployment later, project DNS deployment is also deleted
// So publish project DNS deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`,
auctionId: project.auctionId!,
lrn: deployerLrn,
});
}
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
// Create requests for all the deployers
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
auctionId: project.auctionId!,
lrn: deployerLrn,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`,
});
await this.db.updateDeploymentById(newDeployment.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
});
return newDeployment;
}
async createDeploymentFromData(
userId: string,
data: DeepPartial<Deployment>,
deployerLrn: string,
applicationRecordId: string,
applicationRecordData: ApplicationRecord,
): Promise<Deployment> {
const newDeployment = await this.db.addDeployment({
project: data.project,
branch: data.branch,
@ -593,72 +777,112 @@ export class Service {
createdBy: Object.assign(new User(), {
id: userId,
}),
deployer: Object.assign(new Deployer(), {
deployerLrn,
}),
});
log(
`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`,
);
const environmentVariables =
await this.db.getEnvironmentVariablesByProjectId(data.project.id!, {
environment: Environment.Production,
});
const environmentVariablesObj = environmentVariables.reduce(
(acc, env) => {
acc[env.key] = env.value;
return acc;
},
{} as { [key: string]: string },
);
// To set project DNS
if (data.environment === Environment.Production) {
// On deleting deployment later, project DNS deployment is also deleted
// So publish project DNS deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
await this.registry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`,
});
}
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
await this.registry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`,
});
await this.db.updateDeploymentById(newDeployment.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
});
log(`Created deployment ${newDeployment.id}`);
return newDeployment;
}
async updateProjectWithDeployer(
projectId: string,
deployer: Deployer
): Promise<Deployer> {
const deploymentProject = await this.db.getProjects({
where: { id: projectId },
relations: ['deployers']
});
if (!deploymentProject[0].deployers) {
deploymentProject[0].deployers = [];
}
deploymentProject[0].deployers.push(deployer);
await this.db.saveProject(deploymentProject[0]);
return deployer;
}
async addProjectFromTemplate(
user: User,
organizationSlug: string,
data: AddProjectFromTemplateInput,
lrn?: string,
auctionParams?: AuctionParams,
environmentVariables?: EnvironmentVariables[],
): Promise<Project | undefined> {
try {
const octokit = await this.getOctokit(user.id);
const gitRepo = await octokit?.rest.repos.createUsingTemplate({
template_owner: data.templateOwner,
template_repo: data.templateRepo,
owner: data.owner,
name: data.name,
include_all_branches: false,
private: data.isPrivate,
});
if (!gitRepo) {
throw new Error('Failed to create repository from template');
}
const createdTemplateRepo = await octokit.rest.repos.get({
owner: data.owner,
repo: data.name,
});
const prodBranch = createdTemplateRepo.data.default_branch ?? 'main';
const project = await this.addProject(user, organizationSlug, {
name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`,
prodBranch,
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
paymentAddress: data.paymentAddress,
txHash: data.txHash
}, lrn, auctionParams, environmentVariables);
if (!project || !project.id) {
throw new Error('Failed to create project from template');
}
return project;
} catch (error) {
console.error('Error creating project from template:', error);
throw error;
}
}
async addProject(
user: User,
organizationSlug: string,
data: DeepPartial<Project>,
lrn?: string,
auctionParams?: AuctionParams,
environmentVariables?: EnvironmentVariables[],
): Promise<Project | undefined> {
const organization = await this.db.getOrganization({
where: {
slug: organizationSlug,
},
});
if (!organization) {
throw new Error('Organization does not exist');
}
const project = await this.db.addProject(user, organization.id, data);
if (environmentVariables) {
await this.addEnvironmentVariables(project.id, environmentVariables);
}
const octokit = await this.getOctokit(user.id);
const [owner, repo] = project.repository.split('/');
@ -671,15 +895,56 @@ export class Service {
per_page: 1,
});
// Create deployment with prod branch and latest commit
await this.createDeployment(user.id, octokit, {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
});
if (auctionParams) {
// Create deployment with prod branch and latest commit
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
};
const { applicationDeploymentAuctionId } = await this.laconicRegistry.createApplicationDeploymentAuction(repo, octokit, auctionParams!, deploymentData);
await this.updateProject(project.id, { auctionId: applicationDeploymentAuctionId });
} else {
const deployer = await this.db.getDeployerByLRN(lrn!);
if (!deployer) {
log('Invalid deployer LRN');
return;
}
if (deployer.minimumPayment && project.txHash) {
const amountToBePaid = deployer?.minimumPayment.replace(/\D/g, '').toString();
const txResponse = await this.laconicRegistry.sendTokensToAccount(
deployer?.paymentAddress!,
amountToBePaid
);
const txHash = txResponse.transactionHash;
if (txHash) {
await this.updateProject(project.id, { txHash });
project.txHash = txHash;
log('Funds transferrend to deployer');
}
}
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
deployer
};
const newDeployment = await this.createDeployment(user.id, octokit, deploymentData);
// Update project with deployer
await this.updateProjectWithDeployer(newDeployment.projectId, newDeployment.deployer);
}
await this.createRepoHook(octokit, project);
@ -732,6 +997,9 @@ export class Service {
);
const projects = await this.db.getProjects({
where: { repository: repository.full_name },
relations: {
deployers: true,
}
});
if (!projects.length) {
@ -748,18 +1016,29 @@ export class Service {
branch,
});
// Create deployment with branch and latest commit in GitHub data
await this.createDeployment(project.ownerId, octokit, {
project,
branch,
environment:
project.prodBranch === branch
? Environment.Production
: Environment.Preview,
domain,
commitHash: headCommit.id,
commitMessage: headCommit.message,
});
const deployers = project.deployers;
if (!deployers) {
log(`No deployer present for project ${project.id}`)
return;
}
for (const deployer of deployers) {
// Create deployment with branch and latest commit in GitHub data
await this.createDeployment(project.ownerId, octokit,
{
project,
branch,
environment:
project.prodBranch === branch
? Environment.Production
: Environment.Preview,
domain,
commitHash: headCommit.id,
commitMessage: headCommit.message,
deployer: deployer
},
);
}
}
}
@ -796,6 +1075,7 @@ export class Service {
relations: {
project: true,
domain: true,
deployer: true,
createdBy: true,
},
where: {
@ -809,15 +1089,24 @@ export class Service {
const octokit = await this.getOctokit(user.id);
const newDeployment = await this.createDeployment(user.id, octokit, {
project: oldDeployment.project,
// TODO: Put isCurrent field in project
branch: oldDeployment.branch,
environment: Environment.Production,
domain: oldDeployment.domain,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
});
let newDeployment: Deployment;
if (oldDeployment.project.auctionId) {
newDeployment = await this.createDeploymentFromAuction(oldDeployment.project, oldDeployment.deployer);
} else {
newDeployment = await this.createDeployment(user.id, octokit,
{
project: oldDeployment.project,
// TODO: Put isCurrent field in project
branch: oldDeployment.branch,
environment: Environment.Production,
domain: oldDeployment.domain,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployer: oldDeployment.deployer
}
);
}
return newDeployment;
}
@ -863,16 +1152,18 @@ export class Service {
},
relations: {
project: true,
deployer: true,
},
});
if (deployment && deployment.applicationDeploymentRecordId) {
// If deployment is current, remove deployment for project subdomain as well
if (deployment.isCurrent) {
const currentDeploymentURL = `https://${deployment.project.subDomain}`;
const currentDeploymentURL = `https://${(deployment.project.name).toLowerCase()}.${deployment.deployer.baseDomain}`;
// TODO: Store the latest DNS deployment record
const deploymentRecords =
await this.registry.getDeploymentRecordsByFilter({
await this.laconicRegistry.getDeploymentRecordsByFilter({
application: deployment.applicationRecordId,
url: currentDeploymentURL,
});
@ -885,14 +1176,24 @@ export class Service {
return false;
}
await this.registry.createApplicationDeploymentRemovalRequest({
deploymentId: deploymentRecords[0].id,
// Multiple records are fetched, take the latest record
const latestRecord = deploymentRecords
.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())[0];
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: latestRecord.id,
deployerLrn: deployment.deployer.deployerLrn,
auctionId: deployment.project.auctionId,
payment: deployment.project.txHash
});
}
const result =
await this.registry.createApplicationDeploymentRemovalRequest({
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: deployment.applicationDeploymentRecordId,
deployerLrn: deployment.deployer.deployerLrn,
auctionId: deployment.project.auctionId,
payment: deployment.project.txHash
});
await this.db.updateDeploymentById(deployment.id, {
@ -1027,4 +1328,157 @@ export class Service {
): Promise<boolean> {
return this.db.updateUser(user, data);
}
async getEnvVariables(
projectId: string,
): Promise<{ [key: string]: string }> {
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(projectId, {
environment: Environment.Production,
});
const environmentVariablesObj = environmentVariables.reduce(
(acc, env) => {
acc[env.key] = env.value;
return acc;
},
{} as { [key: string]: string },
);
return environmentVariablesObj;
}
async getAuctionData(
auctionId: string
): Promise<any> {
const auctions = await this.laconicRegistry.getAuctionData(auctionId);
return auctions[0];
}
async releaseDeployerFundsByProjectId(projectId: string): Promise<boolean> {
const project = await this.db.getProjectById(projectId);
if (!project || !project.auctionId) {
log(`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`);
return false;
}
const auction = await this.laconicRegistry.releaseDeployerFunds(project.auctionId);
if (auction.auction.fundsReleased) {
log(`Funds released for auction ${project.auctionId}`);
await this.db.updateProjectById(projectId, { fundsReleased: true });
return true;
}
log(`Error releasing funds for auction ${project.auctionId}`);
return false;
}
async returnUserFundsByProjectId(projectId: string, winningDeployersPresent: boolean) {
const project = await this.db.getProjectById(projectId);
if (!project || !project.auctionId) {
log(`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`);
return false;
}
const auction = await this.getAuctionData(project.auctionId);
const totalAuctionPrice = Number(auction.maxPrice.quantity) * auction.numProviders;
let amountToBeReturned;
if (winningDeployersPresent) {
amountToBeReturned = totalAuctionPrice - auction.winnerAddresses.length * Number(auction.winnerPrice.quantity);
} else {
amountToBeReturned = totalAuctionPrice;
}
if (amountToBeReturned !== 0) {
await this.laconicRegistry.sendTokensToAccount(
project.paymentAddress,
amountToBeReturned.toString()
);
}
}
async getDeployers(): Promise<Deployer[]> {
const dbDeployers = await this.db.getDeployers();
if (dbDeployers.length > 0) {
// Call asynchronously to fetch the records from the registry and update the DB
this.updateDeployersFromRegistry();
return dbDeployers;
} else {
// Fetch from the registry and populate empty DB
return await this.updateDeployersFromRegistry();
}
}
async updateDeployersFromRegistry(): Promise<Deployer[]> {
const deployerRecords = await this.laconicRegistry.getDeployerRecordsByFilter({});
await this.saveDeployersByDeployerRecords(deployerRecords);
return await this.db.getDeployers();
}
async saveDeployersByDeployerRecords(deployerRecords: DeployerRecord[]): Promise<Deployer[]> {
const deployers: Deployer[] = [];
for (const record of deployerRecords) {
if (record.names && record.names.length > 0) {
const deployerId = record.id;
const deployerLrn = record.names[0];
const deployerApiUrl = record.attributes.apiUrl;
const minimumPayment = record.attributes.minimumPayment;
const paymentAddress = record.attributes.paymentAddress;
const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1);
const deployerData = {
deployerLrn,
deployerId,
deployerApiUrl,
baseDomain,
minimumPayment,
paymentAddress
};
// TODO: Update deployers table in a separate job
const deployer = await this.db.addDeployer(deployerData);
deployers.push(deployer);
}
}
return deployers;
}
async getAddress(): Promise<any> {
const account = await this.laconicRegistry.getAccount();
return account.address;
}
async verifyTx(txHash: string, amountSent: string, senderAddress: string): Promise<boolean> {
const txResponse = await this.laconicRegistry.getTxResponse(txHash);
if (!txResponse) {
log('Transaction response not found');
return false;
}
const transfer = txResponse.events.find(e => e.type === 'transfer' && e.attributes.some(a => a.key === 'msg_index'));
if (!transfer) {
log('No transfer event found');
return false;
}
const sender = transfer.attributes.find(a => a.key === 'sender')?.value;
const recipient = transfer.attributes.find(a => a.key === 'recipient')?.value;
const amount = transfer.attributes.find(a => a.key === 'amount')?.value;
const recipientAddress = await this.getAddress();
return amount === amountSent && sender === senderAddress && recipient === recipientAddress;
}
}

View File

@ -29,6 +29,8 @@ export interface GitPushEventPayload {
export interface AppDeploymentRecordAttributes {
application: string;
auction: string;
deployer: string;
dns: string;
meta: string;
name: string;
@ -41,7 +43,7 @@ export interface AppDeploymentRecordAttributes {
export interface AppDeploymentRemovalRecordAttributes {
deployment: string;
request: string;
type: "ApplicationDeploymentRemovalRecord";
type: 'ApplicationDeploymentRemovalRecord';
version: string;
}
@ -61,3 +63,42 @@ export interface AppDeploymentRecord extends RegistryRecord {
export interface AppDeploymentRemovalRecord extends RegistryRecord {
attributes: AppDeploymentRemovalRecordAttributes;
}
export interface AddProjectFromTemplateInput {
templateOwner: string;
templateRepo: string;
owner: string;
name: string;
isPrivate: boolean;
paymentAddress: string;
txHash: string;
}
export interface AuctionParams {
maxPrice: string,
numProviders: number,
}
export interface EnvironmentVariables {
environments: string[],
key: string,
value: string,
}
export interface DeployerRecord {
id: string;
names: string[];
owners: string[];
bondId: string;
createTime: string;
expiryTime: string;
attributes: {
apiUrl: string;
minimumPayment: string | null;
name: string;
paymentAddress: string;
publicKey: string;
type: string;
version: string;
};
}

View File

@ -1,10 +1,14 @@
import assert from 'assert';
import debug from 'debug';
import fs from 'fs-extra';
import { Octokit } from 'octokit';
import path from 'path';
import toml from 'toml';
import debug from 'debug';
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
import { Config } from './config';
import { DEFAULT_CONFIG_FILE_PATH } from './constants';
import { PackageJSON } from './types';
const log = debug('snowball:utils');
@ -76,3 +80,64 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
export const sleep = async (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const getRepoDetails = async (
octokit: Octokit,
repository: string,
commitHash: string | undefined,
): Promise<{
repo: string;
packageJSON: PackageJSON;
repoUrl: string;
}> => {
const [owner, repo] = repository.split('/');
const { data: packageJSONData } = await octokit.rest.repos.getContent({
owner,
repo,
path: 'package.json',
ref: commitHash,
});
if (!packageJSONData) {
throw new Error('Package.json file not found');
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file');
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content));
assert(packageJSON.name, "name field doesn't exist in package.json");
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo,
})
).data.html_url;
return {
repo,
packageJSON,
repoUrl
};
}
// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs
export const registryTransactionWithRetry = async (
txMethod: () => Promise<any>
): Promise<any> => {
try {
return await txMethod();
} catch (error: any) {
if (!error.message.includes('account sequence mismatch')) {
throw error;
}
console.error(`Transaction failed due to account sequence mismatch. Retrying...`);
try {
return await txMethod();
} catch (retryError: any) {
throw new Error(`Transaction failed again after retry: ${retryError.message}`);
}
}
}

View File

@ -2,8 +2,6 @@ import * as fs from 'fs/promises';
import debug from 'debug';
import { getConfig } from '../src/utils';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
const log = debug('snowball:delete-database');
@ -13,7 +11,7 @@ const deleteFile = async (filePath: string) => {
};
const main = async () => {
const config = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const config = await getConfig();
deleteFile(config.database.dbPath);
};

View File

@ -1,8 +1,8 @@
[
{
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Snowball Tools",
"slug": "snowball-tools-1"
"name": "Deploy Tools",
"slug": "deploy-tools"
},
{
"id": "7eb9b3eb-eb74-4b53-b59a-69884c82a7fb",

View File

@ -16,8 +16,6 @@ import {
getEntities,
loadAndSaveData
} from '../src/utils';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
const log = debug('snowball:initialize-database');
@ -156,7 +154,7 @@ const generateTestData = async (dataSource: DataSource) => {
};
const main = async () => {
const config = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const config = await getConfig();
const isDbPresent = await checkFileExists(config.database.dbPath);
if (!isDbPresent) {

View File

@ -1,39 +1,40 @@
import debug from 'debug';
import { Registry } from '@snowballtools/laconic-sdk';
import { parseGasAndFees, Registry } from '@cerc-io/registry-sdk';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { Config } from '../src/config';
import { getConfig } from '../src/utils';
const log = debug('snowball:initialize-registry');
const DENOM = 'aphoton';
const DENOM = 'alnt';
const BOND_AMOUNT = '1000000000';
async function main () {
const { registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const { registryConfig } = await getConfig();
// TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority];
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId);
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, {chainId: registryConfig.chainId});
const bondId = await registry.getNextBondId(registryConfig.privateKey);
log('bondId:', bondId);
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees);
await registry.createBond(
{ denom: DENOM, amount: BOND_AMOUNT },
registryConfig.privateKey,
registryConfig.fee
fee
);
for await (const name of authorityNames) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee);
await registry.reserveAuthority({ name }, registryConfig.privateKey, fee);
log('Reserved authority name:', name);
await registry.setAuthorityBond(
{ name, bondId },
registryConfig.privateKey,
registryConfig.fee
fee
);
log(`Bond ${bondId} set for authority ${name}`);
}

View File

@ -2,22 +2,20 @@ import debug from 'debug';
import { DataSource } from 'typeorm';
import path from 'path';
import { Registry } from '@snowballtools/laconic-sdk';
import { parseGasAndFees, Registry } from '@cerc-io/registry-sdk';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus, Environment } from '../src/entity/Deployment';
const log = debug('snowball:publish-deploy-records');
async function main () {
const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
async function main() {
const { registryConfig, database, misc } = await getConfig();
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
registryConfig.chainId
{ chainId: registryConfig.chainId }
);
const dataSource = new DataSource({
@ -40,7 +38,7 @@ async function main () {
});
for await (const deployment of deployments) {
const url = `https://${deployment.project.name}-${deployment.id}.${misc.projectDomain}`;
const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}`;
const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord',
@ -61,6 +59,8 @@ async function main () {
url
};
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees);
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
@ -68,12 +68,12 @@ async function main () {
bondId: registryConfig.bondId
},
'',
registryConfig.fee
fee
);
// Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.subDomain}`
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}`;
await registry.setRecord(
{
@ -82,12 +82,12 @@ async function main () {
bondId: registryConfig.bondId
},
'',
registryConfig.fee
fee
);
}
log('Application deployment record data:', applicationDeploymentRecord);
log(`Application deployment record published: ${result.data.id}`);
log(`Application deployment record published: ${result.id}`);
}
}

View File

@ -2,22 +2,20 @@ import debug from 'debug';
import { DataSource } from 'typeorm';
import path from 'path';
import { Registry } from '@cerc-io/laconic-sdk';
import { parseGasAndFees, Registry } from '@cerc-io/registry-sdk';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus } from '../src/entity/Deployment';
const log = debug('snowball:publish-deployment-removal-records');
async function main () {
const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const { registryConfig, database } = await getConfig();
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
registryConfig.chainId
{ chainId: registryConfig.chainId }
);
const dataSource = new DataSource({
@ -47,6 +45,8 @@ async function main () {
request: deployment.applicationDeploymentRemovalRequestId,
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees);
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
@ -54,11 +54,11 @@ async function main () {
bondId: registryConfig.bondId
},
'',
registryConfig.fee
fee
);
log('Application deployment removal record data:', applicationDeploymentRemovalRecord);
log(`Application deployment removal record published: ${result.data.id}`);
log(`Application deployment removal record published: ${result.id}`);
}
}

View File

@ -0,0 +1,3 @@
REGISTRY_BOND_ID=
DEPLOYER_LRN=
AUTHORITY=

View File

@ -1,9 +1,11 @@
# deployer
- Install dependencies
```bash
yarn
```
```bash
brew install jq # if you do not have jq installed already
```
@ -12,13 +14,13 @@
- To deploy frontend app to `dashboard.staging.apps.snowballtools.com`
```
```bash
./deploy-frontend.staging.sh
```
- To deploy frontend app to `dashboard.apps.snowballtools.com`
```
```bash
./deploy-frontend.sh
```
@ -27,6 +29,7 @@
## Notes
- Any config env can be updated in [records/application-deployment-request.yml](records/application-deployment-request.yml)
```yml
record:
...
@ -35,6 +38,7 @@
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
...
```
- On changing `LACONIC_HOSTED_CONFIG_app_github_clientid`, the GitHub client ID and secret need to be changed in backend config too
## Troubleshoot
@ -44,14 +48,17 @@
- If deployment fails due to low bond balance
- Check balances
```bash
# Account balance
yarn laconic cns account get
yarn laconic registry account get
# Bond balance
yarn laconic cns bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
yarn laconic registry bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
```
- Command to refill bond
```bash
yarn laconic cns bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type aphoton --quantity 10000000
yarn laconic registry bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type alnt --quantity 10000000
```

View File

@ -1,9 +1,10 @@
services:
cns:
registry:
restEndpoint: 'http://console.laconic.com:1317'
gqlEndpoint: 'http://console.laconic.com:9473/api'
userKey: 87d00f66a73e2ca428adeb49ba9164d0ad9a87edc60e33d46ad3031b9c5701fe
bondId: 89c75c7bc5759861d10285aff6f9e7227d6855e446b77ad5d8324822dfec7deb
chainId: laconic_9000-1
gas: 1200000
fees: 200000aphoton
gas:
fees:
gasPrice: 1

View File

@ -1,9 +1,8 @@
services:
cns:
restEndpoint: http://console.laconic.com:1317
gqlEndpoint: http://console.laconic.com:9473/api
userKey: 489c9dd3931c2a2d4dd77973302dc5eb01e2a49552f9d932c58d9da823512311
bondId: 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
chainId: laconic_9000-1
gas: 1200000
fees: 200000aphoton
registry:
rpcEndpoint: https://laconicd-sapo.laconic.com
gqlEndpoint: https://laconicd-sapo.laconic.com/api
userKey:
bondId:
chainId: laconic_9000-2
gasPrice: 1alnt

View File

@ -1,5 +1,10 @@
#!/bin/bash
source .env
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Using AUTHORITY: $AUTHORITY"
# Repository URL
REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base"
@ -13,58 +18,35 @@ PACKAGE_VERSION=$(jq -r '.version' ../frontend/package.json)
CURRENT_DATE_TIME=$(date -u)
CONFIG_FILE=config.yml
REGISTRY_BOND_ID="99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32"
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "deploy-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found
NEW_APPLICATION_VERSION=0.0.1
fi
# Generate application-deployment-request.yml
cat > ./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: snowballtools-base-frontend@$PACKAGE_VERSION
application: crn://snowballtools/applications/snowballtools-base-frontend@$PACKAGE_VERSION
dns: dashboard
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: b7c63b235ca1dd5639ab
LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
meta:
note: Added by Snowball @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
repository_ref: $LATEST_HASH
EOF
# Generate application-record.yml with incremented version
cat > ./records/application-record.yml <<EOF
cat >./records/application-record.yml <<EOF
record:
type: ApplicationRecord
version: $NEW_APPLICATION_VERSION
repository_ref: $LATEST_HASH
repository: ["$REPO_URL"]
app_type: webapp
name: snowballtools-base-frontend
name: deploy-frontend
app_version: $PACKAGE_VERSION
EOF
echo "Files generated successfully."
echo "Files generated successfully"
RECORD_FILE=records/application-record.yml
# Publish ApplicationRecord
publish_response=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE)
publish_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to publish record"
@ -75,17 +57,17 @@ echo "ApplicationRecord published"
echo $RECORD_ID
# Set name to record
REGISTRY_APP_CRN="crn://snowballtools/applications/snowballtools-base-frontend"
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/deploy-frontend"
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set name: $REGISTRY_APP_CRN@${PACKAGE_VERSION}"
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
exit $rc
fi
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${LATEST_HASH}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set hash"
@ -93,16 +75,16 @@ if [ $rc -ne 0 ]; then
fi
sleep 2
# Set name if latest release
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set release"
exit $rc
fi
echo "$REGISTRY_APP_CRN set for ApplicationRecord"
echo "$REGISTRY_APP_LRN set for ApplicationRecord"
# Check if record found for REGISTRY_APP_CRN
query_response=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN")
# Check if record found for REGISTRY_APP_LRN
query_response=$(yarn --silent laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN")
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query name"
@ -110,14 +92,53 @@ if [ $rc -ne 0 ]; then
fi
APP_RECORD=$(echo $query_response | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_CRN."
echo "No record found for $REGISTRY_APP_LRN."
exit 1
fi
# Get payment address for deployer
paymentAddress=$(yarn --silent laconic -c config.yml registry name resolve "$DEPLOYER_LRN" | jq -r '.[0].attributes.paymentAddress')
paymentAmount=$(yarn --silent laconic -c config.yml registry name resolve "$DEPLOYER_LRN" | jq -r '.[0].attributes.minimumPayment' | sed 's/alnt//g')
# Pay deployer if paymentAmount is not null
if [[ -n "$paymentAmount" && "$paymentAmount" != "null" ]]; then
payment=$(yarn --silent laconic -c config.yml registry tokens send --address "$paymentAddress" --type alnt --quantity "$paymentAmount")
# Extract the transaction hash
txHash=$(echo "$payment" | jq -r '.tx.hash')
echo "Paid deployer with txHash as $txHash"
else
echo "Payment amount is null; skipping payment."
fi
# Generate application-deployment-request.yml
cat >./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: deploy-frontend@$PACKAGE_VERSION
application: lrn://$AUTHORITY/applications/deploy-frontend@$PACKAGE_VERSION
deployer: $DEPLOYER_LRN
dns: deploy
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.apps.vaasl.io
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liaet4yc0KX0iM1c
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: 63cad7ba97391f63652161f484670e15
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2
meta:
note: Added by Snowball @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
repository_ref: $LATEST_HASH
payment: $txHash
EOF
RECORD_FILE=records/application-deployment-request.yml
sleep 2
deployment_response=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE)
deployment_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query deployment request"
exit $rc

View File

@ -18,7 +18,7 @@ REGISTRY_BOND_ID="098c906850b87412f02200e41f449bc79e055eab77acfef32c0b22443bb466
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "staging-snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "staging-snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found
@ -26,21 +26,22 @@ if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; t
fi
# Generate application-deployment-request.yml
cat > ./staging-records/application-deployment-request.yml <<EOF
cat >./staging-records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: staging-snowballtools-base-frontend@$PACKAGE_VERSION
application: crn://staging-snowballtools/applications/staging-snowballtools-base-frontend@$PACKAGE_VERSION
application: lrn://staging-snowballtools/applications/staging-snowballtools-base-frontend@$PACKAGE_VERSION
dns: dashboard.staging.apps.snowballtools.com
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api.staging.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: 905c09553f527d2cdff5
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liOaoahRTYd4nSCV
LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2
LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball
LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc
LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073
@ -54,7 +55,7 @@ record:
EOF
# Generate application-record.yml with incremented version
cat > ./staging-records/application-record.yml <<EOF
cat >./staging-records/application-record.yml <<EOF
record:
type: ApplicationRecord
version: $NEW_APPLICATION_VERSION
@ -70,7 +71,7 @@ echo "Files generated successfully."
RECORD_FILE=staging-records/application-record.yml
# Publish ApplicationRecord
publish_response=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE)
publish_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to publish record"
@ -81,17 +82,17 @@ echo "ApplicationRecord published"
echo $RECORD_ID
# Set name to record
REGISTRY_APP_CRN="crn://staging-snowballtools/applications/staging-snowballtools-base-frontend"
REGISTRY_APP_LRN="lrn://staging-snowballtools/applications/staging-snowballtools-base-frontend"
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set name: $REGISTRY_APP_CRN@${PACKAGE_VERSION}"
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
exit $rc
fi
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${LATEST_HASH}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set hash"
@ -99,16 +100,16 @@ if [ $rc -ne 0 ]; then
fi
sleep 2
# Set name if latest release
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set release"
exit $rc
fi
echo "$REGISTRY_APP_CRN set for ApplicationRecord"
echo "$REGISTRY_APP_LRN set for ApplicationRecord"
# Check if record found for REGISTRY_APP_CRN
query_response=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN")
# Check if record found for REGISTRY_APP_LRN
query_response=$(yarn --silent laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN")
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query name"
@ -116,14 +117,14 @@ if [ $rc -ne 0 ]; then
fi
APP_RECORD=$(echo $query_response | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_CRN."
echo "No record found for $REGISTRY_APP_LRN."
exit 1
fi
RECORD_FILE=staging-records/application-deployment-request.yml
sleep 2
deployment_response=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE)
deployment_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query deployment request"
exit $rc

View File

@ -4,6 +4,6 @@
"main": "index.js",
"private": true,
"devDependencies": {
"@snowballtools/laconic-registry-cli": "^0.1.13"
"@cerc-io/laconic-registry-cli": "^0.2.9"
}
}

View File

@ -1,17 +1,16 @@
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: snowballtools-base-frontend@0.1.8
application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.8
dns: dashboard
name: deploy-frontend@1.0.0
application: lrn://vaasl/applications/deploy-frontend@1.0.0
dns: deploy
config:
env:
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_app_github_clientid: b7c63b235ca1dd5639ab
LACONIC_HOSTED_CONFIG_app_github_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_app_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.apps.vaasl.io
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liaet4yc0KX0iM1c
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: 63cad7ba97391f63652161f484670e15
meta:
note: Added by Snowball @ Thu Apr 4 14:49:41 UTC 2024
repository: "https://git.vdb.to/cerc-io/snowballtools-base"

View File

@ -4,5 +4,5 @@ record:
repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
app_type: webapp
name: snowballtools-base-frontend
app_version: 0.1.8
name: deploy-frontend
app_version: 1.0.0

View File

@ -7,18 +7,18 @@ record:
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api.staging.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: 905c09553f527d2cdff5
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liOaoahRTYd4nSCV
LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball
LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc
LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073
LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: localhost
LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com/
LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: dashboard.staging.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com
LACONIC_HOSTED_CONFIG_turnkey_organization_id: 5049ae99-5bca-40b3-8317-504384d4e591
meta:
note: Added by Snowball @ Tuesday 21 May 2024 06:17:23 AM UTC
note: Added by Snowball @ Mon Jun 24 23:51:48 UTC 2024
repository: "https://git.vdb.to/cerc-io/snowballtools-base"
repository_ref: 8488cfab8353321ed05c4234bf1b914c9ad3aa99
repository_ref: 61e3e88a6c9d57e95441059369ee5a46f5c07601

View File

@ -1,7 +1,7 @@
record:
type: ApplicationRecord
version: 0.0.2
repository_ref: 8488cfab8353321ed05c4234bf1b914c9ad3aa99
version: 0.0.1
repository_ref: 61e3e88a6c9d57e95441059369ee5a46f5c07601
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
app_type: webapp
name: staging-snowballtools-base-frontend

View File

@ -2,7 +2,7 @@
Check if the live web app deployer is in a working state
- Web app repo used: https://github.com/snowball-tools-platform/test-progressive-web-app (main branch)
- Web app repo used: <https://github.com/snowball-tools/test-progressive-web-app> (main branch)
- Config used: [../config.yml](../config.yml)
- The script [test-webapp-deployment-undeployment.sh](./test-webapp-deployment-undeployment.sh) performs the following:
- Create / update [`ApplicationRecord`](./records/application-record.yml) and [`ApplicationDeploymentRequest`](./records/application-deployment-request.yml) records with latest meta data from the repo

View File

@ -1,6 +1,6 @@
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
version: "1.0.0"
name: deployment-test-app@0.1.24
application: crn://snowballtools/applications/deployment-test-app@0.1.24
dns: deployment-ci-test
@ -11,5 +11,5 @@ record:
CERC_WEBAPP_DEBUG: 0
meta:
note: Deployment test @ Thu 11 Apr 2024 07:29:19 AM UTC
repository: "https://github.com/snowball-tools-platform/test-progressive-web-app"
repository: "https://github.com/snowball-tools/test-progressive-web-app"
repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26

View File

@ -2,7 +2,7 @@ record:
type: ApplicationRecord
version: 0.0.1
repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26
repository: ["https://github.com/snowball-tools-platform/test-progressive-web-app"]
repository: ["https://github.com/snowball-tools/test-progressive-web-app"]
app_type: webapp
name: deployment-test-app
app_version: 0.1.24

View File

@ -1,7 +1,7 @@
#!/bin/bash
# Repository URL
REPO_URL="https://github.com/snowball-tools-platform/test-progressive-web-app"
REPO_URL="https://github.com/snowball-tools/test-progressive-web-app"
# Get the latest commit hash from the repository
LATEST_HASH=$(git ls-remote $REPO_URL HEAD | awk '{print $1}')
@ -22,7 +22,7 @@ REGISTRY_BOND_ID="99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be
APP_NAME=deployment-test-app
# Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found
@ -32,7 +32,7 @@ fi
# Generate application-record.yml with incremented version
RECORD_FILE=packages/deployer/test/records/application-record.yml
cat > $RECORD_FILE <<EOF
cat >$RECORD_FILE <<EOF
record:
type: ApplicationRecord
version: $NEW_APPLICATION_VERSION
@ -46,12 +46,12 @@ EOF
# Generate application-deployment-request.yml
REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-request.yml
cat > $REQUEST_RECORD_FILE <<EOF
cat >$REQUEST_RECORD_FILE <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: $APP_NAME@$PACKAGE_VERSION
application: crn://snowballtools/applications/$APP_NAME@$PACKAGE_VERSION
application: lrn://snowballtools/applications/$APP_NAME@$PACKAGE_VERSION
dns: deployment-ci-test
config:
env:
@ -67,31 +67,31 @@ EOF
echo "Record files generated successfully."
# Publish ApplicationRecord
RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE | jq -r '.id')
echo "ApplicationRecord published"
echo $RECORD_ID
# Set name to record
REGISTRY_APP_CRN="crn://snowballtools/applications/$APP_NAME"
REGISTRY_APP_LRN="lrn://snowballtools/applications/$APP_NAME"
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID"
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${LATEST_HASH}" "$RECORD_ID"
sleep 2
# Set name if latest release
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
echo "$REGISTRY_APP_CRN set for ApplicationRecord"
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN" "$RECORD_ID"
echo "$REGISTRY_APP_LRN set for ApplicationRecord"
# Check if record exists for REGISTRY_APP_CRN
APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN" | jq '.[0]')
# Check if record exists for REGISTRY_APP_LRN
APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN" | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_CRN."
echo "No record found for $REGISTRY_APP_LRN."
exit 1
fi
sleep 2
DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $REQUEST_RECORD_FILE | jq -r '.id')
DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $REQUEST_RECORD_FILE | jq -r '.id')
echo "ApplicationDeploymentRequest published"
echo $DEPLOYMENT_REQUEST_ID
@ -102,7 +102,7 @@ MAX_RETRIES=20
# Check that a ApplicationDeploymentRecord is published
retry_count=0
while true; do
deployment_records_response=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationDeploymentRecord --all --name "$APP_NAME" request $DEPLOYMENT_REQUEST_ID)
deployment_records_response=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRecord --all --name "$APP_NAME" request $DEPLOYMENT_REQUEST_ID)
len_deployment_records=$(echo $deployment_records_response | jq 'length')
# Check if number of records returned is 0
@ -115,7 +115,7 @@ while true; do
else
echo "ApplicationDeploymentRecord not found, retrying in $RETRY_INTERVAL sec..."
sleep $RETRY_INTERVAL
retry_count=$((retry_count+1))
retry_count=$((retry_count + 1))
fi
else
echo "ApplicationDeploymentRecord found"
@ -140,7 +140,7 @@ fetched_url=$(echo $deployment_records_response | jq -r '.[0].attributes.url')
retry_count=0
max_retries=10
retry_interval=5
retry_interval=10
while true; do
url_response=$(curl -s -o /dev/null -I -w "%{http_code}" $fetched_url)
if [ "$url_response" = "200" ]; then
@ -154,7 +154,7 @@ while true; do
else
echo "Deployment URL $fetched_url is not active, received code $url_response, retrying in $retry_interval sec..."
sleep $retry_interval
retry_count=$((retry_count+1))
retry_count=$((retry_count + 1))
fi
fi
done
@ -162,7 +162,7 @@ done
# Generate application-deployment-removal-request.yml
REMOVAL_REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-removal-request.yml
cat > $REMOVAL_REQUEST_RECORD_FILE <<EOF
cat >$REMOVAL_REQUEST_RECORD_FILE <<EOF
record:
deployment: $DEPLOYMENT_RECORD_ID
type: ApplicationDeploymentRemovalRequest
@ -170,14 +170,14 @@ record:
EOF
sleep 2
REMOVAL_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
REMOVAL_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
echo "ApplicationDeploymentRemovalRequest published"
echo $REMOVAL_REQUEST_ID
# Check that an ApplicationDeploymentRemovalRecord is published
retry_count=0
while true; do
removal_records_response=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
removal_records_response=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
len_removal_records=$(echo $removal_records_response | jq 'length')
# Check if number of records returned is 0
@ -190,7 +190,7 @@ while true; do
else
echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..."
sleep $RETRY_INTERVAL
retry_count=$((retry_count+1))
retry_count=$((retry_count + 1))
fi
else
echo "ApplicationDeploymentRemovalRecord found"
@ -217,7 +217,7 @@ while true; do
else
echo "Deployment URL $fetched_url is still active, received code $url_response, retrying in $retry_interval sec..."
sleep $retry_interval
retry_count=$((retry_count+1))
retry_count=$((retry_count + 1))
fi
fi
done

View File

@ -14,4 +14,6 @@ VITE_BUGSNAG_API_KEY=
VITE_PASSKEY_WALLET_RPID=
VITE_TURNKEY_API_BASE_URL=
VITE_TURNKEY_ORGANIZATION_ID=
VITE_TURNKEY_ORGANIZATION_ID=
VITE_LACONICD_CHAIN_ID=

View File

@ -9,6 +9,7 @@ import { join, dirname } from 'path';
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')));
}
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
@ -26,6 +27,7 @@ const config: StorybookConfig = {
docs: {
autodocs: 'tag',
},
staticDirs: ['../public'],
};
export default config;

View File

@ -1,30 +1,63 @@
# React + TypeScript + Vite
# frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
This is a [vite](https://vitejs.dev/) [react](https://reactjs.org/) [nextjs](https://nextjs.org/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces).
Currently, two official plugins are available:
## Getting Started
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
### Install dependencies
## Expanding the ESLint configuration
In the root of the project, run:
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
};
```zsh
yarn
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
### Build backend
```zsh
yarn build --ignore frontend
```
### Environment variables
#### Local
Copy the `.env.example` file to `.env`:
```zsh
cp .env.example .env
```
#### Staging environment variables
Change in [deployer/deploy-frontend.staging.sh](/packages/deployer/deploy-frontend.staging.sh)
#### Production environment variables
Change in [deployer/deploy-frontend.sh](/packages/deployer/deploy-frontend.sh)
### Run development server
```zsh
yarn dev
```
## Deployment
From the root of the project,
### Staging
```zsh
cd packages/deployer && ./deploy-frontend.staging.sh
```
### Production
```zsh
cd packages/deployer && ./deploy-frontend.sh
```
### Deployment status
Check the status of the deployment [here](https://webapp-deployer.apps.snowballtools.com)

View File

@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3000",
@ -16,8 +16,11 @@
"@bugsnag/browser-performance": "^2.4.1",
"@bugsnag/js": "^7.22.7",
"@bugsnag/plugin-react": "^7.22.7",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/jetbrains-mono": "^5.0.19",
"@fontsource/inter": "^5.0.16",
"@mui/material": "^6.1.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@ -27,10 +30,6 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@snowballtools/auth": "^0.2.0",
"@snowballtools/auth-lit": "^0.2.0",
"@snowballtools/js-sdk": "^0.1.1",
"@snowballtools/link-lit-alchemy-light": "^0.2.0",
"@snowballtools/material-tailwind-react-fork": "^2.1.10",
"@snowballtools/smartwallet-alchemy-light": "^0.2.0",
"@snowballtools/types": "^0.2.0",
@ -43,8 +42,8 @@
"@turnkey/sdk-react": "^0.1.0",
"@turnkey/webauthn-stamper": "^0.5.0",
"@walletconnect/ethereum-provider": "^2.12.2",
"@web3modal/siwe": "^4.0.5",
"@web3modal/wagmi": "^4.0.5",
"@web3modal/siwe": "4.0.5",
"@web3modal/wagmi": "4.0.5",
"assert": "^2.1.0",
"axios": "^1.6.7",
"clsx": "^2.1.0",
@ -62,15 +61,15 @@
"react-dom": "^18.2.0",
"react-dropdown": "^1.11.0",
"react-hook-form": "^7.49.0",
"react-hot-toast": "^2.4.1",
"react-oauth-popup": "^1.0.5",
"react-router-dom": "^6.20.1",
"react-timer-hook": "^3.0.7",
"siwe": "^2.1.4",
"siwe": "2.1.4",
"tailwind-variants": "^0.2.0",
"usehooks-ts": "^2.15.1",
"uuid": "^9.0.1",
"viem": "^2.7.11",
"wagmi": "2.5.7",
"web-vitals": "^2.1.4"
},
"devDependencies": {

View File

@ -0,0 +1 @@
350e9ac2-8b27-4a79-9a82-78cfdb68ef71=0eacb7ae462f82c8b0199d28193b0bfa5265973dbb1fe991eec2cab737dfc1ec

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Projects from './pages/org-slug';
@ -10,8 +11,8 @@ import ProjectSearchLayout from './layouts/ProjectSearch';
import Index from './pages';
import AuthPage from './pages/AuthPage';
import { DashboardLayout } from './pages/org-slug/layout';
import { useEffect } from 'react';
import Web3Provider from 'context/Web3Provider';
import { BASE_URL } from 'utils/constants';
const router = createBrowserRouter([
{
@ -49,25 +50,26 @@ const router = createBrowserRouter([
path: '/login',
element: <AuthPage />,
},
{
path: '/signup',
element: <AuthPage />,
},
]);
function App() {
// Hacky way of checking session
// TODO: Handle redirect backs
useEffect(() => {
fetch(`${import.meta.env.VITE_SERVER_URL}/auth/session`, {
fetch(`${BASE_URL}/auth/session`, {
credentials: 'include',
}).then((res) => {
const path = window.location.pathname;
if (res.status !== 200) {
localStorage.clear();
const path = window.location.pathname;
if (path !== '/login' && path !== '/signup') {
if (path !== '/login') {
window.location.pathname = '/login';
}
} else {
if (path === '/login') {
window.location.pathname = '/';
}
}
});
}, []);

View File

@ -1,16 +1,21 @@
import {
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO,
VITE_GITHUB_PWA_TEMPLATE_REPO,
} from 'utils/constants';
export default [
{
id: '1',
name: 'Progressive Web App (PWA)',
icon: 'pwa',
repoFullName: `${import.meta.env.VITE_GITHUB_PWA_TEMPLATE_REPO}`,
repoFullName: `${VITE_GITHUB_PWA_TEMPLATE_REPO}`,
isComingSoon: false,
},
{
id: '2',
name: 'Image Upload PWA',
icon: 'pwa',
repoFullName: `${import.meta.env.VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO}`,
repoFullName: `${VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO}`,
isComingSoon: false,
},
{

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useStopwatch } from 'react-timer-hook';
import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond';
@ -12,14 +13,19 @@ const setStopWatchOffset = (time: string) => {
interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> {
offsetTimestamp: Date;
isPaused: boolean;
}
const Stopwatch = ({ offsetTimestamp, ...props }: StopwatchProps) => {
const { totalSeconds } = useStopwatch({
const Stopwatch = ({ offsetTimestamp, isPaused, ...props }: StopwatchProps) => {
const { totalSeconds, pause, start } = useStopwatch({
autoStart: true,
offsetTimestamp: offsetTimestamp,
});
useEffect(() => {
isPaused ? pause() : start();
}, [isPaused]);
return <FormatMillisecond time={totalSeconds * 1000} {...props} />;
};

View File

@ -10,11 +10,16 @@ import {
LinkChainIcon,
} from 'components/shared/CustomIcon';
import { TagProps } from 'components/shared/Tag';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
interface ChangeStateToProductionDialogProps extends ConfirmDialogProps {
deployment: Deployment;
newDeployment?: Deployment;
domains: Domain[];
isConfirmButtonLoading?: boolean;
}
export const ChangeStateToProductionDialog = ({
@ -24,6 +29,7 @@ export const ChangeStateToProductionDialog = ({
open,
handleCancel,
handleConfirm,
isConfirmButtonLoading,
...props
}: ChangeStateToProductionDialogProps) => {
const currentChip = {
@ -41,6 +47,14 @@ export const ChangeStateToProductionDialog = ({
handleCancel={handleCancel}
open={open}
handleConfirm={handleConfirm}
confirmButtonProps={{
disabled: isConfirmButtonLoading,
rightIcon: isConfirmButtonLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
),
}}
>
<div className="flex flex-col gap-7">
<div className="flex flex-col gap-3">

View File

@ -0,0 +1,47 @@
import ConfirmDialog, {
ConfirmDialogProps,
} from 'components/shared/ConfirmDialog';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
interface DeleteDeploymentDialogProps extends ConfirmDialogProps {
isConfirmButtonLoading?: boolean;
}
export const DeleteDeploymentDialog = ({
open,
handleCancel,
handleConfirm,
isConfirmButtonLoading,
...props
}: DeleteDeploymentDialogProps) => {
return (
<ConfirmDialog
{...props}
dialogTitle="Delete deployment?"
handleCancel={handleCancel}
open={open}
confirmButtonTitle={
isConfirmButtonLoading
? 'Deleting deployment'
: 'Yes, delete deployment'
}
handleConfirm={handleConfirm}
confirmButtonProps={{
variant: 'danger',
disabled: isConfirmButtonLoading,
rightIcon: isConfirmButtonLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
),
}}
>
<p className="text-sm text-elements-high-em">
Once deleted, the deployment will not be accessible.
</p>
</ConfirmDialog>
);
};

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useCombobox } from 'downshift';
import { Project } from 'gql-client';
import { useDebounce } from 'usehooks-ts';
import { useDebounceValue } from 'usehooks-ts';
import SearchBar from 'components/SearchBar';
import { useGQLClient } from 'context/GQLClientContext';
@ -42,7 +42,7 @@ export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => {
},
});
const debouncedInputValue = useDebounce<string>(inputValue, 300);
const [debouncedInputValue, _] = useDebounceValue<string>(inputValue, 300);
const fetchProjects = useCallback(
async (inputValue: string) => {
@ -62,7 +62,7 @@ export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => {
<div className="relative w-full lg:w-fit">
<SearchBar {...getInputProps()} />
<div
{...getMenuProps()}
{...getMenuProps({}, { suppressRefError: true })}
className={cn(
'flex flex-col shadow-dropdown rounded-xl bg-surface-card absolute w-[459px] max-h-52 overflow-y-auto px-2 py-2 gap-1 z-50',
{ hidden: !inputValue || !isOpen },

View File

@ -5,7 +5,7 @@ import { CrossIcon, SearchIcon } from 'components/shared/CustomIcon';
import { Input } from 'components/shared/Input';
import { useGQLClient } from 'context/GQLClientContext';
import { Project } from 'gql-client';
import { useDebounce } from 'usehooks-ts';
import { useDebounceValue } from 'usehooks-ts';
import { ProjectSearchBarItem } from './ProjectSearchBarItem';
import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty';
import { useNavigate } from 'react-router-dom';
@ -27,25 +27,30 @@ export const ProjectSearchBarDialog = ({
const client = useGQLClient();
const navigate = useNavigate();
const { getInputProps, getItemProps, inputValue, setInputValue } =
useCombobox({
items,
itemToString(item) {
return item ? item.name : '';
},
selectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
if (newSelectedItem) {
setSelectedItem(newSelectedItem);
onClickItem?.(newSelectedItem);
navigate(
`/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`,
);
}
},
});
const {
getInputProps,
getItemProps,
getMenuProps,
inputValue,
setInputValue,
} = useCombobox({
items,
itemToString(item) {
return item ? item.name : '';
},
selectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
if (newSelectedItem) {
setSelectedItem(newSelectedItem);
onClickItem?.(newSelectedItem);
navigate(
`/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`,
);
}
},
});
const debouncedInputValue = useDebounce<string>(inputValue, 300);
const [debouncedInputValue, _] = useDebounceValue<string>(inputValue, 300);
const fetchProjects = useCallback(
async (inputValue: string) => {
@ -75,7 +80,7 @@ export const ProjectSearchBarDialog = ({
<div className="h-full flex flex-col fixed top-0 inset-0">
<div className="py-2.5 px-4 flex items-center justify-between border-b border-border-separator/[0.06]">
<Input
{...getInputProps()}
{...getInputProps({}, { suppressRefError: true })}
leftIcon={<SearchIcon />}
placeholder="Search"
appearance="borderless"
@ -86,23 +91,33 @@ export const ProjectSearchBarDialog = ({
</Button>
</div>
{/* Content */}
<div className="flex flex-col gap-1 px-2 py-2">
{items.length > 0
? items.map((item, index) => (
<>
<div className="px-2 py-2">
<p className="text-elements-mid-em text-xs font-medium">
Suggestions
</p>
</div>
<ProjectSearchBarItem
key={item.id}
item={item}
{...getItemProps({ item, index })}
/>
</>
))
: inputValue && <ProjectSearchBarEmpty />}
<div
className="flex flex-col gap-1 px-2 py-2"
{...getMenuProps(
{},
{
suppressRefError: true,
},
)}
>
{items.length > 0 ? (
<>
<div className="px-2 py-2">
<p className="text-elements-mid-em text-xs font-medium">
Suggestions
</p>
</div>
{items.map((item, index) => (
<ProjectSearchBarItem
key={item.id}
item={item}
{...getItemProps({ item, index })}
/>
))}
</>
) : (
inputValue && <ProjectSearchBarEmpty />
)}
</div>
</div>
</Dialog.Content>

View File

@ -0,0 +1,571 @@
import { useCallback, useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { FormProvider, FieldValues } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMediaQuery } from 'usehooks-ts';
import {
AddEnvironmentVariableInput,
AuctionParams,
Deployer,
} from 'gql-client';
import { Select, MenuItem, FormControl, FormHelperText } from '@mui/material';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button';
import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext';
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
import { EnvironmentVariablesFormValues } from 'types/types';
import ConnectWallet from './ConnectWallet';
import { useWalletConnectClient } from 'context/WalletConnectContext';
type ConfigureDeploymentFormValues = {
option: string;
lrn?: string;
numProviders?: number;
maxPrice?: string;
};
type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000';
const Configure = () => {
const { signClient, session, accounts } = useWalletConnectClient();
const [isLoading, setIsLoading] = useState(false);
const [deployers, setDeployers] = useState<Deployer[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>();
const [selectedDeployer, setSelectedDeployer] = useState<Deployer>();
const [isPaymentLoading, setIsPaymentLoading] = useState(false);
const [isPaymentDone, setIsPaymentDone] = useState(false);
const [searchParams] = useSearchParams();
const templateId = searchParams.get('templateId');
const queryParams = new URLSearchParams(location.search);
const owner = queryParams.get('owner');
const name = queryParams.get('name');
const defaultBranch = queryParams.get('defaultBranch');
const fullName = queryParams.get('fullName');
const orgSlug = queryParams.get('orgSlug');
const templateOwner = queryParams.get('templateOwner');
const templateRepo = queryParams.get('templateRepo');
const isPrivate = queryParams.get('isPrivate') === 'true';
const navigate = useNavigate();
const { toast, dismiss } = useToast();
const client = useGQLClient();
const methods = useForm<ConfigureFormValues>({
defaultValues: {
option: 'Auction',
maxPrice: DEFAULT_MAX_PRICE,
lrn: '',
numProviders: 1,
variables: [],
},
});
const selectedOption = methods.watch('option');
const isTabletView = useMediaQuery('(min-width: 720px)'); // md:
const buttonSize = isTabletView ? { size: 'lg' as const } : {};
const createProject = async (
data: FieldValues,
envVariables: AddEnvironmentVariableInput[],
senderAddress: string,
txHash: string,
): Promise<string> => {
setIsLoading(true);
let projectId: string | null = null;
try {
let lrn: string | undefined;
let auctionParams: AuctionParams | undefined;
if (data.option === 'LRN') {
lrn = data.lrn;
} else if (data.option === 'Auction') {
auctionParams = {
numProviders: Number(data.numProviders!),
maxPrice: data.maxPrice!.toString(),
};
}
if (templateId) {
const projectData: any = {
templateOwner,
templateRepo,
owner,
name,
isPrivate,
paymentAddress: senderAddress,
txHash,
};
const { addProjectFromTemplate } = await client.addProjectFromTemplate(
orgSlug!,
projectData,
lrn,
auctionParams,
envVariables,
);
projectId = addProjectFromTemplate.id;
} else {
const { addProject } = await client.addProject(
orgSlug!,
{
name: `${owner}-${name}`,
prodBranch: defaultBranch!,
repository: fullName!,
template: 'webapp',
paymentAddress: senderAddress,
txHash,
},
lrn,
auctionParams,
envVariables,
);
projectId = addProject.id;
}
} catch (error) {
console.error('Error creating project:', error);
toast({
id: 'error-creating-project',
title: 'Error creating project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
if (projectId) {
return projectId;
} else {
throw new Error('Project creation failed');
}
};
const verifyTx = async (
senderAddress: string,
txHash: string,
amount: string,
): Promise<boolean> => {
const isValid = await client.verifyTx(
txHash,
`${amount.toString()}alnt`,
senderAddress,
);
return isValid;
};
const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => {
try {
const deployerLrn = createFormData.lrn;
const deployer = deployers.find(
(deployer) => deployer.deployerLrn === deployerLrn,
);
let amount: string;
let senderAddress: string;
let txHash: string;
if (createFormData.option === 'LRN' && !deployer?.minimumPayment) {
toast({
id: 'no-payment-required',
title: 'No payment required. Deploying app...',
variant: 'info',
onDismiss: dismiss,
});
txHash = '';
senderAddress = '';
} else {
if (!selectedAccount) return;
senderAddress = selectedAccount.split(':')[2];
if (createFormData.option === 'LRN') {
amount = deployer?.minimumPayment!;
} else {
amount = (
createFormData.numProviders * createFormData.maxPrice
).toString();
}
const amountToBePaid = amount.replace(/\D/g, '').toString();
const txHashResponse = await cosmosSendTokensHandler(
selectedAccount,
amountToBePaid,
);
if (!txHashResponse) {
console.error('Tx not successful');
return;
}
txHash = txHashResponse;
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amountToBePaid.toString(),
);
if (isTxHashValid === false) {
console.error('Invalid Tx hash', txHash);
return;
}
}
const environmentVariables = createFormData.variables.map(
(variable: any) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
},
);
const projectId = await createProject(
createFormData,
environmentVariables,
senderAddress,
txHash,
);
await client.getEnvironmentVariables(projectId);
if (templateId) {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/template/deploy?projectId=${projectId}&templateId=${templateId}`,
);
} else {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/deploy?projectId=${projectId}`,
);
}
} catch (error) {
console.error(error);
toast({
id: 'error-deploying-app',
title: 'Error deploying app',
variant: 'error',
onDismiss: dismiss,
});
}
},
[client, createProject, dismiss, toast],
);
const fetchDeployers = useCallback(async () => {
const res = await client.getDeployers();
setDeployers(res.deployers);
}, [client]);
const onAccountChange = useCallback((account: string) => {
setSelectedAccount(account);
}, []);
const onDeployerChange = useCallback(
(selectedLrn: string) => {
const deployer = deployers.find((d) => d.deployerLrn === selectedLrn);
setSelectedDeployer(deployer);
},
[deployers],
);
const cosmosSendTokensHandler = useCallback(
async (selectedAccount: string, amount: string) => {
if (!signClient || !session || !selectedAccount) {
return;
}
const chainId = selectedAccount.split(':')[1];
const senderAddress = selectedAccount.split(':')[2];
const snowballAddress = await client.getAddress();
try {
setIsPaymentDone(false);
setIsPaymentLoading(true);
toast({
id: 'sending-payment-request',
title: 'Check your wallet and approve payment request',
variant: 'loading',
onDismiss: dismiss,
});
const result: { signature: string } = await signClient.request({
topic: session.topic,
chainId: `cosmos:${chainId}`,
request: {
method: 'cosmos_sendTokens',
params: [
{
from: senderAddress,
to: snowballAddress,
value: amount,
},
],
},
});
if (!result) {
throw new Error('Error completing transaction');
}
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
setIsPaymentDone(true);
return result.signature;
} catch (error: any) {
console.error('Error sending tokens', error);
toast({
id: 'error-sending-tokens',
title: 'Error sending tokens',
variant: 'error',
onDismiss: dismiss,
});
setIsPaymentDone(false);
} finally {
setIsPaymentLoading(false);
}
},
[session, signClient, toast],
);
useEffect(() => {
fetchDeployers();
}, []);
return (
<div className="space-y-7 px-4 py-6">
<div className="flex justify-between mb-6">
<div className="space-y-1.5">
<Heading as="h4" className="md:text-lg font-medium">
Configure deployment
</Heading>
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
The app can be deployed by setting the deployer LRN for a single
deployment or by creating a deployer auction for multiple
deployments
</Heading>
</div>
</div>
<div className="flex flex-col gap-6 lg:gap-8 w-full">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleFormSubmit)}>
<div className="flex flex-col justify-start gap-4 mb-6">
<Controller
name="option"
control={methods.control}
render={({ field: { value, onChange } }) => (
<Select
value={value}
onChange={(event) => onChange(event.target.value)}
size="small"
displayEmpty
sx={{
fontFamily: 'inherit',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e0e0e0',
borderRadius: '8px',
},
}}
>
<MenuItem value="Auction">Create Auction</MenuItem>
<MenuItem value="LRN">Deployer LRN</MenuItem>
</Select>
)}
/>
</div>
{selectedOption === 'LRN' && (
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em"
>
The app will be deployed by the configured deployer
</Heading>
<Controller
name="lrn"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange }, fieldState }) => (
<FormControl fullWidth error={Boolean(fieldState.error)}>
<span className="text-sm text-elements-high-em mb-4">
Select deployer LRN
</span>
<Select
value={value}
onChange={(event) => {
onChange(event.target.value);
onDeployerChange(event.target.value);
}}
displayEmpty
size="small"
>
{deployers.map((deployer) => (
<MenuItem
key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
{`${deployer.deployerLrn} ${deployer.minimumPayment ? `(${deployer.minimumPayment})` : ''}`}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText>
{fieldState.error.message}
</FormHelperText>
)}
</FormControl>
)}
/>
</div>
)}
{selectedOption === 'Auction' && (
<>
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em"
>
Set the number of deployers and maximum price for each
deployment
</Heading>
<span className="text-sm text-elements-high-em">
Number of Deployers
</span>
<Controller
name="numProviders"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input
type="number"
value={value}
onChange={(e) => onChange(e)}
/>
)}
/>
</div>
<div className="flex flex-col justify-start gap-4 mb-6">
<span className="text-sm text-elements-high-em">
Maximum Price (alnt)
</span>
<Controller
name="maxPrice"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input type="number" value={value} onChange={onChange} />
)}
/>
</div>
</>
)}
<Heading as="h4" className="md:text-lg font-medium mb-3">
Environment Variables
</Heading>
<div className="p-4 bg-slate-100 rounded-lg mb-6">
<EnvironmentVariablesForm />
</div>
{selectedOption === 'LRN' && !selectedDeployer?.minimumPayment ? (
<div>
<Button
{...buttonSize}
type="submit"
disabled={isLoading || !selectedDeployer || !selectedAccount}
rightIcon={
isLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{isLoading ? 'Deploying' : 'Deploy'}
</Button>
</div>
) : (
<>
<Heading as="h4" className="md:text-lg font-medium mb-3">
Connect to your wallet
</Heading>
<ConnectWallet onAccountChange={onAccountChange} />
{accounts && accounts?.length > 0 && (
<div>
<Button
{...buttonSize}
type="submit"
disabled={
isLoading || isPaymentLoading || !selectedAccount
}
rightIcon={
isLoading || isPaymentLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{!isPaymentDone
? isPaymentLoading
? 'Transaction Requested'
: 'Pay and Deploy'
: isLoading
? 'Deploying'
: 'Deploy'}
</Button>
</div>
)}
</>
)}
</form>
</FormProvider>
</div>
</div>
);
};
export default Configure;

View File

@ -14,11 +14,10 @@ import { useToast } from '../../shared/Toast';
import { IconWithFrame } from '../../shared/IconWithFrame';
import { Heading } from '../../shared/Heading';
import { MockConnectGitCard } from './MockConnectGitCard';
import { VITE_GITHUB_CLIENT_ID } from 'utils/constants';
const SCOPES = 'repo user';
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
import.meta.env.VITE_GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(SCOPES)}`;
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${VITE_GITHUB_CLIENT_ID}&scope=${encodeURIComponent(SCOPES)}`;
interface ConnectAccountInterface {
onAuth: (token: string) => void;

View File

@ -0,0 +1,46 @@
import { Select, Option } from '@snowballtools/material-tailwind-react-fork';
import { Button } from '../../shared/Button';
import { useWalletConnectClient } from 'context/WalletConnectContext';
const ConnectWallet = ({
onAccountChange,
}: {
onAccountChange: (selectedAccount: string) => void;
}) => {
const { onConnect, accounts } = useWalletConnectClient();
const handleConnect = async () => {
await onConnect();
};
return (
<div className="p-4 bg-slate-100 rounded-lg mb-6">
{!accounts ? (
<div>
<Button type={'button'} onClick={handleConnect}>
Connect Wallet
</Button>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0].address}
onChange={(value) => {
value && onAccountChange(value);
}}
>
{accounts.map((account, index) => (
<Option key={index} value={account.address}>
{account.address.split(':').slice(1).join(':')}
</Option>
))}
</Select>
</div>
)}
</div>
);
};
export default ConnectWallet;

View File

@ -1,5 +1,7 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import axios from 'axios';
import { Deployment } from 'gql-client';
import { DeployStep, DeployStatus } from './DeployStep';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
@ -7,13 +9,37 @@ import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button';
import { ClockOutlineIcon, WarningIcon } from '../../shared/CustomIcon';
import { CancelDeploymentDialog } from '../../projects/Dialog/CancelDeploymentDialog';
import { useGQLClient } from 'context/GQLClientContext';
const FETCH_DEPLOYMENTS_INTERVAL = 5000;
type RequestState =
| 'SUBMITTED'
| 'DEPLOYING'
| 'DEPLOYED'
| 'REMOVED'
| 'CANCELLED'
| 'ERROR';
type Record = {
id: string;
createTime: string;
app: string;
lastState: RequestState;
lastUpdate: string;
logAvailable: boolean;
};
const TIMEOUT_DURATION = 5000;
const Deploy = () => {
const client = useGQLClient();
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId');
const [open, setOpen] = React.useState(false);
const [deployment, setDeployment] = useState<Deployment>();
const [record, setRecord] = useState<Record>();
const handleOpen = () => setOpen(!open);
const navigate = useNavigate();
@ -23,13 +49,67 @@ const Deploy = () => {
navigate(`/${orgSlug}/projects/create`);
}, []);
useEffect(() => {
const timerID = setTimeout(() => {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}, TIMEOUT_DURATION);
const isDeploymentFailed = useMemo(() => {
if (!record) {
return false;
}
return () => clearInterval(timerID);
}, []);
// Not checking for `REMOVED` status as this status is received for a brief period before receiving `DEPLOYED` status
if (record.lastState === 'CANCELLED' || record.lastState === 'ERROR') {
return true;
} else {
return false;
}
}, [record]);
const fetchDeploymentRecords = useCallback(async () => {
if (!deployment) {
return;
}
try {
const response = await axios.get(
`${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`,
);
const record: Record = response.data;
setRecord(record);
} catch (err: any) {
console.log('Error fetching data from deployer', err);
}
}, [deployment]);
const fetchDeployment = useCallback(async () => {
if (!projectId) {
return;
}
const { deployments } = await client.getDeployments(projectId);
setDeployment(deployments[0]);
}, [client, projectId]);
useEffect(() => {
fetchDeployment();
fetchDeploymentRecords();
const interval = setInterval(() => {
fetchDeploymentRecords();
}, FETCH_DEPLOYMENTS_INTERVAL);
return () => {
clearInterval(interval);
};
}, [fetchDeployment, fetchDeploymentRecords]);
useEffect(() => {
if (!record) {
return;
}
if (record.lastState === 'DEPLOYED') {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}
}, [record]);
return (
<div className="space-y-7">
@ -42,6 +122,7 @@ const Deploy = () => {
<ClockOutlineIcon size={16} className="text-elements-mid-em" />
<Stopwatch
offsetTimestamp={setStopWatchOffset(Date.now().toString())}
isPaused={isDeploymentFailed}
/>
</div>
</div>
@ -60,30 +141,36 @@ const Deploy = () => {
/>
</div>
<div>
<DeployStep
title="Building"
status={DeployStatus.COMPLETE}
step="1"
processTime="72000"
/>
<DeployStep
title="Deployment summary"
status={DeployStatus.PROCESSING}
step="2"
startTime={Date.now().toString()}
/>
<DeployStep
title="Running checks"
status={DeployStatus.NOT_STARTED}
step="3"
/>
<DeployStep
title="Assigning domains"
status={DeployStatus.NOT_STARTED}
step="4"
/>
</div>
{!isDeploymentFailed ? (
<div>
<DeployStep
title={record ? 'Submitted' : 'Submitting'}
status={record ? DeployStatus.COMPLETE : DeployStatus.PROCESSING}
step="1"
/>
<DeployStep
title={
record && record.lastState === 'DEPLOYED'
? 'Deployed'
: 'Deploying'
}
status={
!record
? DeployStatus.NOT_STARTED
: record.lastState === 'DEPLOYED'
? DeployStatus.COMPLETE
: DeployStatus.PROCESSING
}
step="2"
startTime={Date.now().toString()}
/>
</div>
) : (
<div>
<DeployStep title={record!.lastState} status={DeployStatus.ERROR} />
</div>
)}
</div>
);
};

View File

@ -1,27 +1,16 @@
import { useState } from 'react';
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import FormatMillisecond from '../../FormatMilliSecond';
import processLogs from '../../../assets/process-logs.json';
import { cn } from 'utils/classnames';
import {
CheckRoundFilledIcon,
ClockOutlineIcon,
CopyIcon,
LoaderIcon,
MinusCircleIcon,
PlusIcon,
} from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
import { useIntersectionObserver } from 'usehooks-ts';
enum DeployStatus {
PROCESSING = 'progress',
COMPLETE = 'complete',
NOT_STARTED = 'notStarted',
ERROR = 'error',
}
interface DeployStepsProps {
@ -32,35 +21,11 @@ interface DeployStepsProps {
processTime?: string;
}
const DeployStep = ({
step,
status,
title,
startTime,
processTime,
}: DeployStepsProps) => {
const [isOpen, setIsOpen] = useState(false);
const { toast, dismiss } = useToast();
const { isIntersecting: hideGradientOverlay, ref } = useIntersectionObserver({
threshold: 1,
});
const disableCollapse = status !== DeployStatus.COMPLETE;
const DeployStep = ({ step, status, title, startTime }: DeployStepsProps) => {
return (
<div className="border-b border-border-separator">
{/* Collapisble trigger */}
<button
className={cn(
'flex justify-between w-full py-5 gap-2',
disableCollapse && 'cursor-auto',
)}
tabIndex={disableCollapse ? -1 : undefined}
onClick={() => {
if (!disableCollapse) {
setIsOpen((val) => !val);
}
}}
className={cn('flex justify-between w-full py-5 gap-2', 'cursor-auto')}
>
<div className={cn('grow flex items-center gap-3')}>
{/* Icon */}
@ -73,12 +38,6 @@ const DeployStep = ({
{status === DeployStatus.PROCESSING && (
<LoaderIcon className="animate-spin text-elements-link" />
)}
{status === DeployStatus.COMPLETE && (
<div className="text-controls-primary">
{!isOpen && <PlusIcon size={24} />}
{isOpen && <MinusCircleIcon size={24} />}
</div>
)}
</div>
{/* Title */}
@ -96,7 +55,10 @@ const DeployStep = ({
{status === DeployStatus.PROCESSING && (
<div className="flex items-center gap-1.5">
<ClockOutlineIcon size={16} className="text-elements-low-em" />
<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
<Stopwatch
offsetTimestamp={setStopWatchOffset(startTime!)}
isPaused={false}
/>
</div>
)}
{status === DeployStatus.COMPLETE && (
@ -107,51 +69,9 @@ const DeployStep = ({
size={15}
/>
</div>
<FormatMillisecond time={Number(processTime)} />{' '}
</div>
)}
</button>
{/* Collapsible */}
<Collapse open={isOpen}>
<div className="relative text-xs text-elements-low-em h-36 overflow-y-auto">
{/* Logs */}
{processLogs.map((log, key) => {
return (
<p className="font-mono" key={key}>
{log}
</p>
);
})}
{/* End of logs ref used for hiding gradient overlay */}
<div ref={ref} />
{/* Overflow gradient overlay */}
{!hideGradientOverlay && (
<div className="h-14 w-full sticky bottom-0 inset-x-0 bg-gradient-to-t from-white to-transparent" />
)}
{/* Copy log button */}
<div className={cn('sticky bottom-4 left-1/2 flex justify-center')}>
<Button
size="xs"
onClick={() => {
navigator.clipboard.writeText(processLogs.join('\n'));
toast({
title: 'Logs copied',
variant: 'success',
id: 'logs',
onDismiss: dismiss,
});
}}
leftIcon={<CopyIcon size={16} />}
>
Copy log
</Button>
</div>
</div>
</Collapse>
</div>
);
};

View File

@ -38,36 +38,9 @@ export const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({
});
}
try {
setIsLoading(true);
const { addProject } = await client.addProject(orgSlug, {
name: `${repository.owner?.login}-${repository.name}`,
prodBranch: repository.default_branch as string,
repository: repository.full_name,
// TODO: Compute template from repo
template: 'webapp',
});
if (addProject) {
navigate(`import?projectId=${addProject.id}`);
} else {
toast({
id: 'failed-to-create-project',
title: 'Failed to create project',
variant: 'error',
onDismiss: dismiss,
});
}
} catch (error) {
console.error((error as Error).message);
toast({
id: 'failed-to-create-project',
title: 'Failed to create project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
navigate(
`configure?owner=${repository.owner?.login}&name=${repository.name}&defaultBranch=${repository.default_branch}&fullName=${repository.full_name}&orgSlug=${orgSlug}`,
);
}, [client, repository, orgSlug, setIsLoading, navigate, toast]);
return (

View File

@ -64,9 +64,9 @@ export const RepositoryList = () => {
// Check if selected account is an organization
if (selectedAccount.value === gitUser.login) {
query = query + ` user:${selectedAccount}`;
query = query + ` user:${selectedAccount.value}`;
} else {
query = query + ` org:${selectedAccount}`;
query = query + ` org:${selectedAccount.value}`;
}
const result = await octokit.rest.search.repos({

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import {
Deployment,
DeploymentStatus,
@ -6,6 +6,15 @@ import {
Environment,
Project,
} from 'gql-client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tooltip,
} from '@mui/material';
import { Avatar } from 'components/shared/Avatar';
import {
BranchStrokeIcon,
@ -18,12 +27,23 @@ import {
import { Heading } from 'components/shared/Heading';
import { OverflownText } from 'components/shared/OverflownText';
import { Tag, TagTheme } from 'components/shared/Tag';
import { Button } from 'components/shared/Button';
import { getInitials } from 'utils/geInitials';
import { relativeTimeMs } from 'utils/time';
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
import { formatAddress } from '../../../../utils/format';
import { DeploymentMenu } from './DeploymentMenu';
const DEPLOYMENT_LOGS_STYLE = {
backgroundColor: 'rgba(0,0,0, .9)',
padding: '2em',
borderRadius: '0.5em',
marginLeft: '0.5em',
marginRight: '0.5em',
color: 'gray',
fontSize: 'small',
};
interface DeployDetailsCardProps {
deployment: Deployment;
currentDeployment: Deployment;
@ -48,6 +68,14 @@ const DeploymentDetailsCard = ({
project,
prodBranchDomains,
}: DeployDetailsCardProps) => {
const [openDialog, setOpenDialog] = useState<boolean>(false);
const [deploymentLogs, setDeploymentLogs] = useState<string>(
'No deployment logs available',
);
const handleOpenDialog = () => setOpenDialog(true);
const handleCloseDialog = () => setOpenDialog(false);
const getIconByDeploymentStatus = (status: DeploymentStatus) => {
if (
status === DeploymentStatus.Building ||
@ -64,18 +92,39 @@ const DeploymentDetailsCard = ({
}
};
const fetchDeploymentLogs = async () => {
setDeploymentLogs('Loading logs...');
handleOpenDialog();
const statusUrl = `${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`;
const statusRes = await fetch(statusUrl, { cache: 'no-store' }).then(
(res) => res.json(),
);
if (!statusRes.logAvailable) {
setDeploymentLogs(statusRes.lastState);
} else {
const logsUrl = `${deployment.deployer.deployerApiUrl}/log/${deployment.applicationDeploymentRequestId}`;
const logsRes = await fetch(logsUrl, { cache: 'no-store' }).then((res) =>
res.text(),
);
setDeploymentLogs(logsRes);
}
};
const renderDeploymentStatus = useCallback(
(className?: string) => {
return (
<div className={className}>
<Tag
leftIcon={getIconByDeploymentStatus(deployment.status)}
size="xs"
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
>
{deployment.status}
</Tag>
</div>
<Tooltip title="Click to view build logs">
<div className={className} style={{ cursor: 'pointer' }}>
<Tag
leftIcon={getIconByDeploymentStatus(deployment.status)}
size="xs"
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
onClick={fetchDeploymentLogs}
>
{deployment.status}
</Tag>
</div>
</Tooltip>
);
},
[deployment.status, deployment.commitHash],
@ -83,7 +132,7 @@ const DeploymentDetailsCard = ({
return (
<div className="flex md:flex-row flex-col gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent relative">
<div className="flex-1 flex justify-between w-full md:max-w-[25%] lg:max-w-[28%]">
<div className="flex-1 flex justify-between w-full md:max-w-[30%] lg:max-w-[33%]">
<div className="flex-1 w-full space-y-2 max-w-[90%] sm:max-w-full">
{/* DEPLOYMENT URL */}
{deployment.url && (
@ -96,7 +145,12 @@ const DeploymentDetailsCard = ({
</OverflownText>
</Heading>
)}
<span className="text-sm text-elements-low-em tracking-tight">
{deployment.deployer.deployerLrn && (
<span className="text-sm text-elements-low-em tracking-tight block mt-2">
Deployer LRN: {deployment.deployer.deployerLrn}
</span>
)}
<span className="text-sm text-elements-low-em tracking-tight block">
{deployment.environment === Environment.Production
? `Production ${deployment.isCurrent ? '(Current)' : ''}`
: 'Preview'}
@ -162,6 +216,20 @@ const DeploymentDetailsCard = ({
prodBranchDomains={prodBranchDomains}
/>
</div>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
fullWidth
maxWidth="md"
>
<DialogTitle>Deployment logs</DialogTitle>
<DialogContent style={DEPLOYMENT_LOGS_STYLE}>
{deploymentLogs && <pre>{deploymentLogs}</pre>}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Close</Button>
</DialogActions>
</Dialog>
</div>
);
};

View File

@ -23,6 +23,7 @@ import { useGQLClient } from 'context/GQLClientContext';
import { cn } from 'utils/classnames';
import { ChangeStateToProductionDialog } from 'components/projects/Dialog/ChangeStateToProductionDialog';
import { useToast } from 'components/shared/Toast';
import { DeleteDeploymentDialog } from 'components/projects/Dialog/DeleteDeploymentDialog';
interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> {
deployment: Deployment;
@ -46,12 +47,16 @@ export const DeploymentMenu = ({
const [changeToProduction, setChangeToProduction] = useState(false);
const [redeployToProduction, setRedeployToProduction] = useState(false);
const [deleteDeploymentDialog, setDeleteDeploymentDialog] = useState(false);
const [isConfirmDeleteLoading, setIsConfirmDeleteLoading] = useState(false);
const [rollbackDeployment, setRollbackDeployment] = useState(false);
const [assignDomainDialog, setAssignDomainDialog] = useState(false);
const [isConfirmButtonLoading, setConfirmButtonLoadingLoading] =
useState(false);
const updateDeployment = async () => {
const isUpdated = await client.updateDeploymentToProd(deployment.id);
if (isUpdated) {
if (isUpdated.updateDeploymentToProd) {
await onUpdate();
toast({
id: 'deployment_changed_to_production',
@ -71,7 +76,8 @@ export const DeploymentMenu = ({
const redeployToProd = async () => {
const isRedeployed = await client.redeployToProd(deployment.id);
if (isRedeployed) {
setConfirmButtonLoadingLoading(false);
if (isRedeployed.redeployToProd) {
await onUpdate();
toast({
id: 'redeployed_to_production',
@ -94,7 +100,7 @@ export const DeploymentMenu = ({
project.id,
deployment.id,
);
if (isRollbacked) {
if (isRollbacked.rollbackDeployment) {
await onUpdate();
toast({
id: 'deployment_rolled_back',
@ -114,11 +120,15 @@ export const DeploymentMenu = ({
const deleteDeployment = async () => {
const isDeleted = await client.deleteDeployment(deployment.id);
if (isDeleted) {
setIsConfirmDeleteLoading(false);
setDeleteDeploymentDialog((preVal) => !preVal);
if (isDeleted.deleteDeployment) {
await onUpdate();
toast({
id: 'deployment_deleted',
title: 'Deployment deleted',
id: 'deployment_removal_requested',
title: 'Deployment removal requested',
variant: 'success',
onDismiss: dismiss,
});
@ -202,7 +212,7 @@ export const DeploymentMenu = ({
</MenuItem>
<MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => deleteDeployment()}
onClick={() => setDeleteDeploymentDialog((preVal) => !preVal)}
>
<CrossCircleIcon /> Delete deployment
</MenuItem>
@ -228,11 +238,13 @@ export const DeploymentMenu = ({
open={redeployToProduction}
confirmButtonTitle="Redeploy"
handleConfirm={async () => {
setConfirmButtonLoadingLoading(true);
await redeployToProd();
setRedeployToProduction((preVal) => !preVal);
}}
deployment={deployment}
domains={deployment.domain ? [deployment.domain] : []}
isConfirmButtonLoading={isConfirmButtonLoading}
/>
{Boolean(currentDeployment) && (
<ChangeStateToProductionDialog
@ -253,6 +265,15 @@ export const DeploymentMenu = ({
open={assignDomainDialog}
handleOpen={() => setAssignDomainDialog(!assignDomainDialog)}
/>
<DeleteDeploymentDialog
open={deleteDeploymentDialog}
handleConfirm={async () => {
setIsConfirmDeleteLoading(true);
await deleteDeployment();
}}
handleCancel={() => setDeleteDeploymentDialog((preVal) => !preVal)}
isConfirmButtonLoading={isConfirmDeleteLoading}
/>
</>
);
};

View File

@ -0,0 +1,171 @@
import { useCallback, useEffect, useState } from 'react';
import { Auction, Deployer, Project } from 'gql-client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
CheckRoundFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { useGQLClient } from 'context/GQLClientContext';
import { Button, Heading, Tag } from 'components/shared';
const WAIT_DURATION = 5000;
const DIALOG_STYLE = {
backgroundColor: 'rgba(0,0,0, .9)',
padding: '2em',
borderRadius: '0.5em',
marginLeft: '0.5em',
marginRight: '0.5em',
color: 'gray',
fontSize: 'small',
};
export const AuctionCard = ({ project }: { project: Project }) => {
const [auctionStatus, setAuctionStatus] = useState<string>('');
const [deployers, setDeployers] = useState<Deployer[]>([]);
const [fundsStatus, setFundsStatus] = useState<boolean>(false);
const [auctionDetails, setAuctionDetails] = useState<Auction | null>(null);
const [openDialog, setOpenDialog] = useState<boolean>(false);
const client = useGQLClient();
const getIconByAuctionStatus = (status: string) =>
status === 'completed' ? (
<CheckRoundFilledIcon />
) : (
<LoadingIcon className="animate-spin" />
);
const checkAuctionStatus = useCallback(async () => {
const result = await client.getAuctionData(project.auctionId);
setAuctionStatus(result.status);
setAuctionDetails(result);
}, [project.auctionId, project.deployers, project.fundsReleased]);
const fetchUpdatedProject = useCallback(async () => {
const updatedProject = await client.getProject(project.id);
setDeployers(updatedProject.project!.deployers!);
setFundsStatus(updatedProject.project!.fundsReleased!);
}, [project.id]);
const fetchData = useCallback(async () => {
await Promise.all([checkAuctionStatus(), fetchUpdatedProject()]);
}, [checkAuctionStatus, fetchUpdatedProject]);
useEffect(() => {
fetchData();
const timerId = setInterval(() => {
fetchData();
}, WAIT_DURATION);
return () => clearInterval(timerId);
}, [fetchData]);
const renderAuctionStatus = useCallback(
() => (
<Tag
leftIcon={getIconByAuctionStatus(auctionStatus)}
size="xs"
type={auctionStatus === 'completed' ? 'positive' : 'emphasized'}
>
{auctionStatus.toUpperCase()}
</Tag>
),
[auctionStatus],
);
const handleOpenDialog = () => setOpenDialog(true);
const handleCloseDialog = () => setOpenDialog(false);
return (
<>
<div className="p-3 gap-2 rounded-xl border border-gray-200 transition-colors hover:bg-base-bg-alternate flex flex-col mt-8">
<div className="flex justify-between items-center">
<Heading className="text-lg leading-6 font-medium">
Auction details
</Heading>
<Button onClick={handleOpenDialog} variant="tertiary" size="sm">
View details
</Button>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Auction Id
</span>
<span className="text-elements-mid-em text-sm text-right">
{project.auctionId}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Auction Status
</span>
<div className="ml-2">{renderAuctionStatus()}</div>
</div>
{auctionStatus === 'completed' && (
<>
{deployers?.length > 0 ? (
<div>
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Deployer LRNs
</span>
{deployers.map((deployer, index) => (
<p key={index} className="text-elements-mid-em text-sm">
{'\u2022'} {deployer.deployerLrn}
</p>
))}
<div className="flex justify-between items-center mt-1">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Deployer Funds Status
</span>
<div className="ml-2">
<Tag
size="xs"
type={fundsStatus ? 'positive' : 'emphasized'}
>
{fundsStatus ? 'RELEASED' : 'WAITING'}
</Tag>
</div>
</div>
</div>
) : (
<div className="mt-3">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
No winning deployers
</span>
</div>
)}
</>
)}
</div>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
fullWidth
maxWidth="md"
>
<DialogTitle>Auction Details</DialogTitle>
<DialogContent style={DIALOG_STYLE}>
{auctionDetails && (
<pre>{JSON.stringify(auctionDetails, null, 2)}</pre>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Close</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@ -61,7 +61,9 @@ const DeleteProjectDialog = ({
<Input
label={
"Deleting your project is irreversible. Enter your project's name " +
'"' +
project.name +
'"' +
' below to confirm you want to permanently delete it:'
}
id="input"

View File

@ -2,7 +2,6 @@ import { useState } from 'react';
import { Domain, DomainStatus, Project } from 'gql-client';
import {
Chip,
Typography,
Menu,
MenuHandler,
@ -15,6 +14,15 @@ import EditDomainDialog from './EditDomainDialog';
import { useGQLClient } from 'context/GQLClientContext';
import { DeleteDomainDialog } from 'components/projects/Dialog/DeleteDomainDialog';
import { useToast } from 'components/shared/Toast';
import { Tag } from 'components/shared/Tag';
import {
CheckIcon,
CrossIcon,
GearIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Heading } from 'components/shared/Heading';
import { Button } from 'components/shared/Button';
enum RefreshStatus {
IDLE,
@ -79,22 +87,29 @@ const DomainCard = ({
<>
<div className="flex justify-between py-3">
<div className="flex justify-start gap-1">
<Typography variant="h6">
<i>^</i> {domain.name}
</Typography>
<Chip
className="w-fit capitalize"
value={domain.status}
color={domain.status === DomainStatus.Live ? 'green' : 'orange'}
variant="ghost"
icon={<i>^</i>}
/>
<Heading as="h6" className="flex-col">
{domain.name}{' '}
<Tag
type={
domain.status === DomainStatus.Live ? 'positive' : 'negative'
}
leftIcon={
domain.status === DomainStatus.Live ? (
<CheckIcon />
) : (
<CrossIcon />
)
}
>
{domain.status}
</Tag>
</Heading>
</div>
<div className="flex justify-start gap-1">
<i
id="refresh"
className="cursor-pointer w-8 h-8"
className="cursor-pointer"
onClick={() => {
SetRefreshStatus(RefreshStatus.CHECKING);
setTimeout(() => {
@ -102,11 +117,17 @@ const DomainCard = ({
}, CHECK_FAIL_TIMEOUT);
}}
>
{refreshStatus === RefreshStatus.CHECKING ? 'L' : 'R'}
{refreshStatus === RefreshStatus.CHECKING ? (
<LoadingIcon className="animate-spin" />
) : (
'L'
)}
</i>
<Menu placement="bottom-end">
<MenuHandler>
<button className="border-2 rounded-full w-8 h-8">...</button>
<Button iconOnly>
<GearIcon />
</Button>
</MenuHandler>
<MenuList>
<MenuItem
@ -143,13 +164,13 @@ const DomainCard = ({
{domain.status === DomainStatus.Pending && (
<Card className="bg-slate-100 p-4 text-sm">
{refreshStatus === RefreshStatus.IDLE ? (
<Typography variant="small">
<Heading>
^ Add these records to your domain and refresh to check
</Typography>
</Heading>
) : refreshStatus === RefreshStatus.CHECKING ? (
<Typography variant="small" className="text-blue-500">
<Heading className="text-blue-500">
^ Checking records for {domain.name}
</Typography>
</Heading>
) : (
<div className="flex gap-2 text-red-500 mb-2">
<div className="grow">

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Controller, useForm, SubmitHandler } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Domain } from 'gql-client';
import {
@ -9,10 +8,11 @@ import {
Option,
} from '@snowballtools/material-tailwind-react-fork';
import { useGQLClient } from '../../../../context/GQLClientContext';
import { useGQLClient } from 'context/GQLClientContext';
import { Modal } from 'components/shared/Modal';
import { Button } from 'components/shared/Button';
import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
const DEFAULT_REDIRECT_OPTIONS = ['none'];
@ -40,6 +40,7 @@ const EditDomainDialog = ({
onUpdate,
}: EditDomainDialogProp) => {
const client = useGQLClient();
const { toast, dismiss } = useToast();
const getRedirectUrl = (domain: Domain) => {
const redirectDomain = domain.redirectTo;
@ -99,10 +100,20 @@ const EditDomainDialog = ({
if (updateDomain) {
await onUpdate();
toast.success(`Domain ${domain.name} has been updated`);
toast({
id: 'domain_id_updated',
title: `Domain ${domain.name} has been updated`,
variant: 'success',
onDismiss: dismiss,
});
} else {
reset();
toast.error(`Error updating domain ${domain.name}`);
toast({
id: 'domain_id_error_update',
title: `Error updating domain ${domain.name}`,
variant: 'error',
onDismiss: dismiss,
});
}
handleOpen();

View File

@ -3,6 +3,8 @@ import { useState } from 'react';
import { DeleteWebhookDialog } from 'components/projects/Dialog/DeleteWebhookDialog';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
import { Input } from 'components/shared/Input';
import { CopyIcon, TrashIcon } from 'components/shared/CustomIcon';
interface WebhookCardProps {
webhookUrl: string;
@ -14,11 +16,12 @@ const WebhookCard = ({ webhookUrl, onDelete }: WebhookCardProps) => {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
return (
<div className="flex justify-between w-full mb-3">
{webhookUrl}
<div className="flex justify-between w-full mb-3 gap-3">
<Input value={webhookUrl} disabled />
<div className="flex gap-3">
<Button
size="sm"
iconOnly
size="md"
onClick={() => {
navigator.clipboard.writeText(webhookUrl);
toast({
@ -29,16 +32,17 @@ const WebhookCard = ({ webhookUrl, onDelete }: WebhookCardProps) => {
});
}}
>
Copy
<CopyIcon />
</Button>
<Button
size="sm"
iconOnly
size="md"
variant="danger"
onClick={() => {
setDeleteDialogOpen(true);
}}
>
X
<TrashIcon />
</Button>
</div>
<DeleteWebhookDialog

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { NavLink, useNavigate, useParams } from 'react-router-dom';
import { Organization, User } from 'gql-client';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { User } from 'gql-client';
import { motion } from 'framer-motion';
import { useDisconnect } from 'wagmi';
import { useGQLClient } from 'context/GQLClientContext';
import {
@ -18,8 +19,7 @@ import { getInitials } from 'utils/geInitials';
import { Button } from 'components/shared/Button';
import { cn } from 'utils/classnames';
import { useMediaQuery } from 'usehooks-ts';
import { SIDEBAR_MENU } from './constants';
import { UserSelect } from 'components/shared/UserSelect';
import { BASE_URL } from 'utils/constants';
interface SidebarProps {
mobileOpen?: boolean;
@ -32,6 +32,7 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
const isDesktop = useMediaQuery('(min-width: 960px)');
const [user, setUser] = useState<User>();
const { disconnect } = useDisconnect();
const fetchUser = useCallback(async () => {
const { user } = await client.getUser();
@ -42,54 +43,15 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
fetchUser();
}, []);
const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const fetchUserOrganizations = useCallback(async () => {
const { organizations } = await client.getOrganizations();
setOrganizations(organizations);
}, [orgSlug]);
useEffect(() => {
fetchUserOrganizations();
setSelectedOrgSlug(orgSlug);
}, [orgSlug]);
const formattedSelected = useMemo(() => {
const selected = organizations.find((org) => org.slug === selectedOrgSlug);
return {
value: selected?.slug ?? '',
label: selected?.name ?? '',
imgSrc: '/logo.svg',
};
}, [organizations, selectedOrgSlug, orgSlug]);
const formattedSelectOptions = useMemo(() => {
return organizations.map((org) => ({
value: org.slug,
label: org.name,
imgSrc: '/logo.svg',
}));
}, [organizations, selectedOrgSlug, orgSlug]);
const renderMenu = useMemo(() => {
return SIDEBAR_MENU(orgSlug).map(({ title, icon, url }, index) => (
<NavLink to={url} key={index}>
<Tabs.Trigger icon={icon} value={title}>
{title}
</Tabs.Trigger>
</NavLink>
));
}, [orgSlug]);
const handleLogOut = useCallback(async () => {
await fetch(`${import.meta.env.VITE_SERVER_URL}/auth/logout`, {
await fetch(`${BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
localStorage.clear();
disconnect();
navigate('/login');
}, [navigate]);
}, [disconnect, navigate]);
return (
<motion.nav
@ -113,16 +75,8 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
<div className="hidden lg:flex">
<Logo orgSlug={orgSlug} />
</div>
{/* Switch organization */}
<div className="flex flex-1 flex-col gap-4">
<UserSelect
value={formattedSelected}
options={formattedSelectOptions}
/>
<Tabs defaultValue="Projects" orientation="vertical">
<Tabs.List>{renderMenu}</Tabs.List>
</Tabs>
</div>
{/* This element ensures the space between logo and navigation */}
<div className="flex-1"></div>
{/* Bottom navigation */}
<div className="flex flex-col gap-5 justify-end">
<Tabs defaultValue="Projects" orientation="vertical">

View File

@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react';
import { Input } from '../Input';
export interface VerifyCodeInputProps {
code: string;
setCode: (code: string) => void;
submitCode: () => void;
loading: boolean;
}
export const VerifyCodeInput = ({
code,
setCode,
submitCode,
loading,
}: VerifyCodeInputProps) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handlePaste = (
e: React.ClipboardEvent<HTMLInputElement>,
i: number,
) => {
e.preventDefault();
const pasteData = e.clipboardData.getData('text').replace(/\D/g, ''); // Only digits
if (pasteData.length > 0) {
let newCodeArray = code.split('');
for (let j = 0; j < pasteData.length && i + j < 6; j++) {
newCodeArray[i + j] = pasteData[j];
}
const newCode = newCodeArray.join('');
setCode(newCode);
const nextIndex = Math.min(i + pasteData.length, 5);
const nextInput = inputRefs.current[nextIndex];
if (nextInput) nextInput.focus();
if (!newCode.includes(' ')) {
submitCode();
}
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
i: number,
) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (e.key === 'Backspace') {
e.preventDefault();
const isEmpty = code[i] === ' ';
const newCode = !isEmpty
? code.slice(0, i) + ' ' + code.slice(i + 1, 6)
: code.slice(0, i - 1) + ' ' + code.slice(i, 6);
setCode(newCode.slice(0, 6));
if (i === 0 || !isEmpty) return;
const prev = inputRefs.current[i - 1];
if (prev) prev.focus();
return;
}
if (!e.key.match(/[0-9]/)) return;
e.preventDefault(); // Prevent the default event to avoid duplicate input
const newCode = code.slice(0, i) + e.key + code.slice(i + 1, 6);
setCode(newCode);
if (i === 5) {
submitCode();
return;
}
const next = inputRefs.current[i + 1];
if (next) next.focus();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, i: number) => {
const value = e.target.value.slice(-1);
if (!value.match(/[0-9]/)) return;
const newCode = code.slice(0, i) + value + code.slice(i + 1, 6);
setCode(newCode);
if (i < 5) {
const next = inputRefs.current[i + 1];
if (next) next.focus();
}
if (!newCode.includes(' ')) {
submitCode();
}
};
useEffect(() => {
if (inputRefs.current[0]) {
inputRefs.current[0].focus();
}
}, []);
return (
<div className="flex gap-2">
{code.split('').map((char, i) => (
<Input
key={i}
value={char === ' ' ? '' : char}
ref={(el) => (inputRefs.current[i] = el)}
onChange={(e) => handleChange(e, i)}
onPaste={(e) => handlePaste(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
disabled={!!loading}
style={{ textAlign: 'center' }} // Add this line to center text
/>
))}
</div>
);
};

View File

@ -0,0 +1 @@
export { VerifyCodeInput } from './VerifyCodeInput';

View File

@ -0,0 +1,27 @@
export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Button } from './Button';
export { Calendar } from './Calendar';
export { Checkbox } from './Checkbox';
export { DatePicker } from './DatePicker';
export { DotBorder } from './DotBorder';
export { Heading } from './Heading';
export { IconWithFrame } from './IconWithFrame';
export { InlineNotification } from './InlineNotification';
export { Input } from './Input';
export { Modal } from './Modal';
export { OverflownText } from './OverflownText';
export { Radio } from './Radio';
export { SegmentedControls } from './SegmentedControls';
export { Select } from './Select';
export { Sidebar } from './Sidebar';
export { Step } from './Steps';
export { Switch } from './Switch';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { Tag } from './Tag';
export { useToast } from './Toast';
export { Tooltip } from './Tooltip';
export { UserSelect } from './UserSelect';
export { VerifyCodeInput } from './VerifyCodeInput';
export { WavyBorder } from './WavyBorder';

View File

@ -0,0 +1,210 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import SignClient from '@walletconnect/sign-client';
import { getSdkError } from '@walletconnect/utils';
import { SessionTypes } from '@walletconnect/types';
import { walletConnectModal } from '../utils/web3modal';
import {
VITE_LACONICD_CHAIN_ID,
VITE_WALLET_CONNECT_ID,
} from 'utils/constants';
interface ClientInterface {
signClient: SignClient | undefined;
session: SessionTypes.Struct | undefined;
loadingSession: boolean;
onConnect: () => Promise<void>;
onDisconnect: () => Promise<void>;
onSessionDelete: () => void;
accounts: { address: string }[] | undefined;
}
const ClientContext = createContext({} as ClientInterface);
export const useWalletConnectClient = () => {
return useContext(ClientContext);
};
export const WalletConnectClientProvider = ({
children,
}: {
children: JSX.Element;
}) => {
const [signClient, setSignClient] = useState<SignClient>();
const [session, setSession] = useState<SessionTypes.Struct>();
const [loadingSession, setLoadingSession] = useState(true);
const [accounts, setAccounts] = useState<{ address: string }[]>();
const isSignClientInitializing = useRef<boolean>(false);
const onSessionConnect = useCallback(async (session: SessionTypes.Struct) => {
setSession(session);
}, []);
const subscribeToEvents = useCallback(
async (client: SignClient) => {
client.on('session_update', ({ topic, params }) => {
const { namespaces } = params;
const currentSession = client.session.get(topic);
const updatedSession = { ...currentSession, namespaces };
setSession(updatedSession);
});
},
[setSession],
);
const onConnect = async () => {
const proposalNamespace = {
cosmos: {
methods: ['cosmos_sendTokens'],
chains: [`cosmos:${VITE_LACONICD_CHAIN_ID}`],
events: [],
},
};
try {
const { uri, approval } = await signClient!.connect({
requiredNamespaces: proposalNamespace,
});
if (uri) {
walletConnectModal.openModal({ uri });
const session = await approval();
onSessionConnect(session);
walletConnectModal.closeModal();
}
} catch (e) {
console.error(e);
}
};
const onDisconnect = useCallback(async () => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session === 'undefined') {
throw new Error('Session is not connected');
}
await signClient.disconnect({
topic: session.topic,
reason: getSdkError('USER_DISCONNECTED'),
});
onSessionDelete();
}, [signClient, session]);
const onSessionDelete = () => {
setAccounts(undefined);
setSession(undefined);
};
const checkPersistedState = useCallback(
async (signClient: SignClient) => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session !== 'undefined') return;
if (signClient.session.length) {
const lastKeyIndex = signClient.session.keys.length - 1;
const previousSsession = signClient.session.get(
signClient.session.keys[lastKeyIndex],
);
await onSessionConnect(previousSsession);
return previousSsession;
}
},
[session, onSessionConnect],
);
const createClient = useCallback(async () => {
isSignClientInitializing.current = true;
try {
const signClient = await SignClient.init({
projectId: VITE_WALLET_CONNECT_ID,
metadata: {
name: 'Deploy App',
description: '',
url: window.location.href,
icons: ['https://avatars.githubusercontent.com/u/92608123'],
},
});
setSignClient(signClient);
await checkPersistedState(signClient);
await subscribeToEvents(signClient);
setLoadingSession(false);
} catch (e) {
console.error('error in createClient', e);
}
isSignClientInitializing.current = false;
}, [setSignClient, checkPersistedState, subscribeToEvents]);
useEffect(() => {
if (!signClient && !isSignClientInitializing.current) {
createClient();
}
}, [signClient, createClient]);
useEffect(() => {
const populateAccounts = async () => {
if (!session) {
return;
}
if (!session.namespaces['cosmos']) {
console.log('Accounts for cosmos namespace not found');
return;
}
const cosmosAddresses = session.namespaces['cosmos'].accounts;
const cosmosAccounts = cosmosAddresses.map((address) => ({
address,
}));
const allAccounts = cosmosAccounts;
setAccounts(allAccounts);
};
populateAccounts();
}, [session]);
useEffect(() => {
if (!signClient) {
return;
}
signClient.on('session_delete', onSessionDelete);
return () => {
signClient.off('session_delete', onSessionDelete);
};
});
return (
<ClientContext.Provider
value={{
signClient,
onConnect,
onDisconnect,
onSessionDelete,
loadingSession,
session,
accounts,
}}
>
{children}
</ClientContext.Provider>
);
};

View File

@ -1,14 +1,116 @@
import { ReactNode } from 'react';
import assert from 'assert';
import { SiweMessage, generateNonce } from 'siwe';
import { WagmiProvider } from 'wagmi';
import { mainnet } from 'wagmi/chains';
import axios from 'axios';
import { createWeb3Modal } from '@web3modal/wagmi/react';
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config';
import { createSIWEConfig } from '@web3modal/siwe';
import type {
SIWECreateMessageArgs,
SIWEVerifyMessageArgs,
} from '@web3modal/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
import { VITE_WALLET_CONNECT_ID, BASE_URL } from 'utils/constants';
if (!import.meta.env.VITE_WALLET_CONNECT_ID) {
if (!VITE_WALLET_CONNECT_ID) {
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set');
}
assert(BASE_URL, 'VITE_SERVER_URL is not set in env');
export default function Web3Provider({ children }: { children: ReactNode }) {
const queryClient = new QueryClient();
const axiosInstance = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
withCredentials: true,
});
const metadata = {
name: 'Deploy App Auth',
description: '',
url: window.location.origin,
icons: ['https://avatars.githubusercontent.com/u/37784886'],
};
const chains = [mainnet] as const;
const config = defaultWagmiConfig({
chains,
projectId: VITE_WALLET_CONNECT_ID,
metadata,
});
const siweConfig = createSIWEConfig({
createMessage: ({ nonce, address, chainId }: SIWECreateMessageArgs) =>
new SiweMessage({
version: '1',
domain: window.location.host,
uri: window.location.origin,
address,
chainId,
nonce,
// Human-readable ASCII assertion that the user will sign, and it must not contain `\n`.
statement: 'Sign in With Ethereum.',
}).prepareMessage(),
getNonce: async () => {
return generateNonce();
},
getSession: async () => {
try {
const session = (await axiosInstance.get('/auth/session')).data;
const { address, chainId } = session;
return { address, chainId };
} catch (err) {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
throw new Error('Failed to get session!');
}
},
verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => {
try {
const { success } = (
await axiosInstance.post('/auth/validate', {
message,
signature,
})
).data;
return success;
} catch (error) {
return false;
}
},
signOut: async () => {
try {
const { success } = (await axiosInstance.post('/auth/logout')).data;
return success;
} catch (error) {
return false;
}
},
onSignOut: () => {
window.location.href = '/login';
},
onSignIn: () => {
window.location.href = '/';
},
});
createWeb3Modal({
siweConfig,
wagmiConfig: config,
projectId: VITE_WALLET_CONNECT_ID,
});
export default function Web3ModalProvider({
children,
}: {
children: ReactNode;
}) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}

View File

@ -14,28 +14,34 @@ import { GQLClientProvider } from './context/GQLClientContext';
import { SERVER_GQL_PATH } from './constants';
import { Toaster } from 'components/shared/Toast';
import { LogErrorBoundary } from 'utils/log-error';
import { BASE_URL } from 'utils/constants';
import Web3ModalProvider from './context/Web3Provider';
import { WalletConnectClientProvider } from 'context/WalletConnectContext';
// @ts-ignore
console.log(`v-${__VERSION__}`);
console.log(`v-0.0.9`);
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
assert(import.meta.env.VITE_SERVER_URL, 'VITE_SERVER_URL is not set in env');
const gqlEndpoint = `${import.meta.env.VITE_SERVER_URL}/${SERVER_GQL_PATH}`;
assert(BASE_URL, 'VITE_SERVER_URL is not set in env');
const gqlEndpoint = `${BASE_URL}/${SERVER_GQL_PATH}`;
const gqlClient = new GQLClient({ gqlEndpoint });
root.render(
<LogErrorBoundary>
<React.StrictMode>
<ThemeProvider>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster />
</GQLClientProvider>
</ThemeProvider>
<WalletConnectClientProvider>
<ThemeProvider>
<Web3ModalProvider>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster />
</GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider>
</WalletConnectClientProvider>
</React.StrictMode>
</LogErrorBoundary>,
);

View File

@ -32,7 +32,7 @@ const ProjectSearch = () => {
return (
<section className="h-full flex flex-col">
{/* Header */}
<div className="sticky hidden lg:block top-0 border-b bg-base-bg border-border-separator/[0.06] z-30">
<div className="sticky hidden lg:block top-0 border-b bg-base-bg border-border-separator/[0.06] hover:z-30">
<div className="flex pr-6 pl-2 py-2 items-center">
<div className="flex-1">
<ProjectSearchBar

View File

@ -1,5 +1,5 @@
import { CloudyFlow } from 'components/CloudyFlow';
import { SnowballAuth } from './auth/SnowballAuth';
import { Login } from './auth/Login';
const AuthPage = () => {
return (
@ -18,7 +18,7 @@ const AuthPage = () => {
</div>
<div className="pb-12 relative z-10 flex-1 flex-center">
<div className="max-w-[520px] w-full bg-white rounded-xl shadow">
<SnowballAuth />
<Login />
</div>
</div>
</CloudyFlow>

View File

@ -1,83 +0,0 @@
import { Button } from 'components/shared/Button';
import { LoaderIcon } from 'components/shared/CustomIcon';
import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon';
import { InlineNotification } from 'components/shared/InlineNotification';
import { Input } from 'components/shared/Input';
import { WavyBorder } from 'components/shared/WavyBorder';
import { useState } from 'react';
import { IconRight } from 'react-day-picker';
import { useSnowball } from 'utils/use-snowball';
type Props = {
onDone: () => void;
};
export const CreatePasskey = ({}: Props) => {
const snowball = useSnowball();
const [name, setName] = useState('');
const auth = snowball.auth.passkey;
const loading = !!auth.state.loading;
async function createPasskey() {
await auth.register(name);
}
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="w-16 h-16 p-2 bg-sky-100 rounded-[800px] justify-center items-center gap-2 inline-flex">
<KeyIcon />
</div>
<div>
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-loose">
Create a passkey
</div>
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Passkeys allow you to sign in securely without using passwords.
</div>
</div>
</div>
<WavyBorder className="self-stretch" variant="stroke" />
<div className="p-6 flex-col justify-center items-center gap-8 inline-flex">
<div className="self-stretch h-36 flex-col justify-center items-center gap-2 flex">
<div className="self-stretch h-[72px] flex-col justify-start items-start gap-2 flex">
<div className="self-stretch h-5 px-1 flex-col justify-start items-start gap-1 flex">
<div className="self-stretch text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
Give it a name
</div>
</div>
<Input
value={name}
onInput={(e: any) => {
setName(e.target.value);
}}
/>
</div>
{auth.state.error ? (
<InlineNotification
title={auth.state.error.message}
variant="danger"
/>
) : (
<InlineNotification
title={`Once you press the "Create passkeys" button, you'll receive a prompt to create the passkey.`}
variant="info"
/>
)}
</div>
<Button
rightIcon={
loading ? <LoaderIcon className="animate-spin" /> : <IconRight />
}
className="self-stretch"
disabled={!name || loading}
onClick={createPasskey}
>
Create Passkey
</Button>
</div>
</div>
);
};

View File

@ -1,100 +1,6 @@
import { Button } from 'components/shared/Button';
import {
ArrowRightCircleFilledIcon,
GithubIcon,
LinkIcon,
LoaderIcon,
QuestionMarkRoundFilledIcon,
} from 'components/shared/CustomIcon';
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
import { DotBorder } from 'components/shared/DotBorder';
import { WavyBorder } from 'components/shared/WavyBorder';
import { useEffect, useState } from 'react';
import { CreatePasskey } from './CreatePasskey';
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon';
import { useToast } from 'components/shared/Toast';
import { Link } from 'react-router-dom';
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
import { signInWithEthereum } from 'utils/siwe';
import { useSnowball } from 'utils/use-snowball';
import { logError } from 'utils/log-error';
type Provider = 'google' | 'github' | 'apple' | 'email' | 'passkey';
type Props = {
onDone: () => void;
};
export const Login = ({ onDone }: Props) => {
const snowball = useSnowball();
const [error, setError] = useState<string>('');
const [provider, setProvider] = useState<Provider | false>(false);
// const loading = snowball.auth.state.loading && provider;
const loading = provider;
const { toast } = useToast();
if (provider === 'email') {
return <CreatePasskey onDone={onDone} />;
}
async function handleSigninRedirect() {
let wallet: PKPEthersWallet | undefined;
const { google } = snowball.auth;
if (google.canHandleOAuthRedirectBack()) {
setProvider('google');
console.log('Handling google redirect back');
try {
await google.handleOAuthRedirectBack();
// @ts-ignore
wallet = await google.getEthersWallet();
// @ts-ignore
const result = await signInWithEthereum(1, 'login', wallet);
if (result.error) {
setError(result.error);
setProvider(false);
wallet = undefined;
logError(new Error(result.error));
return;
}
} catch (err: any) {
setError(err.message);
logError(err);
setProvider(false);
return;
}
}
// if (apple.canHandleOAuthRedirectBack()) {
// setProvider('apple');
// console.log('Handling apple redirect back');
// try {
// await apple.handleOAuthRedirectBack();
// wallet = await apple.getEthersWallet();
// const result = await signInWithEthereum(1, 'login', wallet);
// if (result.error) {
// setError(result.error);
// setProvider(false);
// wallet = undefined;
// return;
// }
// } catch (err: any) {
// setError(err.message);
// console.log(err.message, err.name, err.details);
// setProvider(false);
// return;
// }
// }
if (wallet) {
window.location.pathname = '/';
}
}
useEffect(() => {
handleSigninRedirect();
}, []);
export const Login = () => {
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
@ -105,160 +11,8 @@ export const Login = ({ onDone }: Props) => {
<WavyBorder className="self-stretch" variant="stroke" />
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
<div className="self-stretch p-5 bg-slate-50 rounded-xl shadow flex-col justify-center items-center gap-6 flex">
<div className="self-stretch flex-col justify-center items-center gap-4 flex">
<KeyIcon />
<div className="self-stretch flex-col justify-center items-center gap-2 flex">
<div className="self-stretch text-center text-sky-950 text-lg font-medium font-display leading-normal">
Got a Passkey?
</div>
<div className="self-stretch text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Use it to sign in securely without using a password.
</div>
</div>
</div>
<div className="self-stretch justify-center items-stretch xxs:items-center gap-3 flex flex-col xxs:flex-row">
<Button
as="a"
leftIcon={<QuestionMarkRoundFilledIcon />}
variant={'tertiary'}
target="_blank"
href="https://safety.google/authentication/passkey/"
>
Learn more
</Button>
<Button
rightIcon={
loading && loading === 'passkey' ? (
<LoaderIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon height="16" />
)
}
className="flex-1"
disabled={!!loading}
onClick={async () => {
setProvider('passkey');
}}
>
Sign In with Passkey
</Button>
</div>
<div className="h-5 justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Lost your passkey?
</div>
<div className="justify-center items-center gap-1.5 flex">
<button className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight">
Recover account
</button>
<LinkIcon />
</div>
</div>
</div>
<div className="self-stretch justify-start items-center gap-8 inline-flex">
<DotBorder className="flex-1" />
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
OR
</div>
<DotBorder className="flex-1" />
</div>
<div className="self-stretch flex-col justify-center items-center gap-3 flex">
<Button
leftIcon={<GoogleIcon />}
rightIcon={
loading && loading === 'google' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={() => {
setProvider('google');
snowball.auth.google.startOAuthRedirect();
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with Google
</Button>
<Button
leftIcon={<GithubIcon />}
rightIcon={
loading && loading === 'github' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={async () => {
setProvider('github');
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with GitHub is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with GitHub
</Button>
<Button
leftIcon={<AppleIcon />}
rightIcon={
loading && loading === 'apple' ? (
<LoaderIcon className="animate-spin text-white" />
) : null
}
onClick={async () => {
setProvider('apple');
// snowball.auth.apple.startOAuthRedirect();
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with Apple is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
loading && loading === 'apple' ? 'disabled:bg-black' : ''
}`}
variant={'tertiary'}
disabled={!!loading}
>
Continue with Apple
</Button>
</div>
<div className="flex flex-col gap-3">
{error && (
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">Error: {error}</div>
</div>
)}
<div className="h-5 justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Don't have an account?
</div>
<div className="justify-center items-center gap-1.5 flex">
<Link
to="/signup"
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
>
Sign up now
</Link>
</div>
</div>
<w3m-button />
</div>
</div>
</div>

View File

@ -1,263 +0,0 @@
import { Button } from 'components/shared/Button';
import {
ArrowRightCircleFilledIcon,
GithubIcon,
LoaderIcon,
} from 'components/shared/CustomIcon';
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
import { DotBorder } from 'components/shared/DotBorder';
import { WavyBorder } from 'components/shared/WavyBorder';
import { useEffect, useState } from 'react';
import { useSnowball } from 'utils/use-snowball';
import { Input } from 'components/shared/Input';
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
import { Link } from 'react-router-dom';
import { useToast } from 'components/shared/Toast';
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
import { signInWithEthereum } from 'utils/siwe';
import { logError } from 'utils/log-error';
import {
subOrganizationIdForEmail,
turnkeySignin,
turnkeySignup,
} from 'utils/turnkey-frontend';
type Provider = 'google' | 'github' | 'apple' | 'email';
type Err = { type: 'email' | 'provider'; message: string };
type Props = {
onDone: () => void;
};
export const SignUp = ({ onDone }: Props) => {
const [email, setEmail] = useState('');
const [error, setError] = useState<Err | null>();
const [provider, setProvider] = useState<Provider | false>(false);
const { toast } = useToast();
const snowball = useSnowball();
async function handleSignupRedirect() {
let wallet: PKPEthersWallet | undefined;
const { google } = snowball.auth;
if (google.canHandleOAuthRedirectBack()) {
setProvider('google');
try {
await google.handleOAuthRedirectBack();
// @ts-ignore
wallet = await google.getEthersWallet();
// @ts-ignore
const result = await signInWithEthereum(1, 'signup', wallet);
if (result.error) {
setError({ type: 'provider', message: result.error });
setProvider(false);
wallet = undefined;
logError(new Error(result.error));
return;
}
} catch (err: any) {
setError({ type: 'provider', message: err.message });
setProvider(false);
logError(err);
return;
}
}
// if (apple.canHandleOAuthRedirectBack()) {
// setProvider('apple');
// try {
// await apple.handleOAuthRedirectBack();
// wallet = await apple.getEthersWallet();
// const result = await signInWithEthereum(1, 'signup', wallet);
// if (result.error) {
// setError({ type: 'provider', message: result.error });
// setProvider(false);
// wallet = undefined;
// return;
// }
// } catch (err: any) {
// setError({ type: 'provider', message: err.message });
// setProvider(false);
// return;
// }
// }
if (wallet) {
onDone();
}
}
async function authEmail() {
setProvider('email');
try {
const orgId = await subOrganizationIdForEmail(email);
console.log('orgId', orgId);
if (orgId) {
await turnkeySignin(orgId);
window.location.href = '/dashboard';
} else {
await turnkeySignup(email);
onDone();
}
} catch (err: any) {
setError({ type: 'email', message: err.message });
}
}
useEffect(() => {
handleSignupRedirect();
}, []);
const loading = provider;
const emailValid = /.@./.test(email);
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-tight">
Sign up to Snowball
</div>
</div>
<WavyBorder className="self-stretch" variant="stroke" />
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
<div className="self-stretch flex-col justify-center items-center gap-3 flex">
<Button
leftIcon={loading && loading === 'google' ? null : <GoogleIcon />}
rightIcon={
loading && loading === 'google' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={() => {
setProvider('google');
snowball.auth.google.startOAuthRedirect();
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with Google
</Button>
<Button
leftIcon={<GithubIcon />}
rightIcon={
loading && loading === 'github' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={async () => {
setProvider('github');
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with GitHub is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with GitHub
</Button>
<Button
leftIcon={<AppleIcon />}
rightIcon={
loading && loading === 'apple' ? (
<LoaderIcon className="animate-spin text-white" />
) : null
}
onClick={async () => {
setProvider('apple');
// snowball.auth.apple.startOAuthRedirect();
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with Apple is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
loading && loading === 'apple' ? 'disabled:bg-black' : ''
}`}
variant={'tertiary'}
disabled={!!loading}
>
Continue with Apple
</Button>
</div>
{error && error.type === 'provider' && (
<div className="-mt-3 justify-center items-center inline-flex">
<div className="text-red-500 text-sm">Error: {error.message}</div>
</div>
)}
<div className="self-stretch justify-start items-center gap-8 inline-flex">
<DotBorder className="flex-1" />
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
OR
</div>
<DotBorder className="flex-1" />
</div>
<div className="self-stretch flex-col gap-8 flex">
<div className="flex-col justify-start items-start gap-2 inline-flex">
<div className="text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
Email
</div>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!!loading}
/>
</div>
<Button
rightIcon={
loading && loading === 'email' ? (
<LoaderIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon height="16" />
)
}
onClick={() => {
authEmail();
}}
variant={'secondary'}
disabled={!email || !emailValid || !!loading}
>
Continue with Email
</Button>
<div className="flex flex-col gap-3">
{error && error.type === 'email' && (
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">
Error: {error.message}
</div>
</div>
)}
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Already an user?
</div>
<div className="justify-center items-center gap-1.5 flex">
<Link
to="/login"
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
>
Sign in now
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,52 +0,0 @@
import { snowball } from 'utils/use-snowball';
import { Login } from './Login';
import { SignUp } from './SignUp';
import { useEffect, useState } from 'react';
import { Done } from './Done';
type Screen = 'login' | 'signup' | 'success';
const DASHBOARD_URL = '/';
export const SnowballAuth = () => {
const path = window.location.pathname;
const [screen, setScreen] = useState<Screen>(
path === '/login' ? 'login' : 'signup',
);
useEffect(() => {
if (snowball.session) {
window.location.href = DASHBOARD_URL;
}
}, []);
useEffect(() => {
if (path === '/login') {
setScreen('login');
} else if (path === '/signup') {
setScreen('signup');
}
}, [path]);
if (screen === 'signup') {
return (
<SignUp
onDone={() => {
setScreen('success');
}}
/>
);
}
if (screen === 'login') {
return (
<Login
onDone={() => {
setScreen('success');
}}
/>
);
}
if (screen === 'success') {
return <Done continueTo={DASHBOARD_URL} />;
}
};

View File

@ -36,6 +36,13 @@ const deployment: Deployment = {
url: 'https://deploy1.example.com',
environment: Environment.Production,
isCurrent: true,
deployer: {
deployerApiUrl: 'https://webapp-deployer-api.example.com',
deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu',
deployerLrn: 'lrn://example/deployers/webapp-deployer-api.example.com',
minimumPayment: '1000alnt',
baseDomain: 'pwa.example.com',
},
status: DeploymentStatus.Ready,
createdBy: {
id: 'user1',
@ -48,6 +55,8 @@ const deployment: Deployment = {
},
createdAt: '1677676800', // 2023-03-01T12:00:00Z
updatedAt: '1677680400', // 2023-03-01T13:00:00Z
applicationDeploymentRequestId:
'bafyreiaycvq6imoppnpwdve4smj6t6ql5svt5zl3x6rimu4qwyzgjorize',
};
const domains: Domain[] = [

View File

@ -1,5 +1,19 @@
const Settings = () => {
return <div className="p-5">Settings page</div>;
};
import { Heading } from 'components/shared/Heading';
const Settings = () => {
return (
<section className="px-4 md:px-6 py-6 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center">
<div className="grow">
<div className="flex gap-4 items-center">
<Heading as="h2" className="text-[24px]">
Settings
</Heading>
</div>
</div>
</div>
</section>
);
};
export default Settings;

View File

@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Project } from 'gql-client';
import { Button } from 'components/shared/Button';
import { PlusIcon } from 'components/shared/CustomIcon';
import { ProjectCard } from 'components/projects/ProjectCard';
import { Heading } from 'components/shared/Heading';
import { Badge } from 'components/shared/Badge';
import { Heading, Badge, Button } from 'components/shared';
import { PlusIcon } from 'components/shared/CustomIcon';
import { useGQLClient } from 'context/GQLClientContext';
import { Project } from 'gql-client';
const Projects = () => {
const client = useGQLClient();
@ -28,6 +26,7 @@ const Projects = () => {
return (
<section className="px-4 md:px-6 py-6 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center">
<div className="grow">
<div className="flex gap-4 items-center">

View File

@ -92,9 +92,13 @@ const Id = () => {
Open repo
</Button>
</Link>
<Button {...buttonSize} className="h-11 transition-colors">
Go to app
</Button>
{(project.deployments.length > 0) &&
<Link to={`https://${project.name.toLowerCase()}.${project.deployments[0].deployer.baseDomain}`}>
<Button {...buttonSize} className="h-11 transition-colors">
Go to app
</Button>
</Link>
}
</div>
</div>
<WavyBorder />

View File

@ -31,6 +31,11 @@ const CreateWithTemplate = () => {
},
{
step: 2,
route: `/${orgSlug}/projects/create/template/configure`,
label: 'Configure',
},
{
step: 3,
route: `/${orgSlug}/projects/create/template/deploy`,
label: 'Deploy',
},

View File

@ -2,7 +2,8 @@ import NewProject from './index';
import CreateWithTemplate from './Template';
import { templateRoutes } from './template/routes';
import Id from './success/Id';
import Import from './Import';
import Configure from 'components/projects/create/Configure';
import Deploy from 'components/projects/create/Deploy';
export const createProjectRoutes = [
{
@ -19,7 +20,11 @@ export const createProjectRoutes = [
element: <Id />,
},
{
path: 'import',
element: <Import />,
path: 'configure',
element: <Configure />,
},
{
path: 'deploy',
element: <Deploy />,
},
];

View File

@ -1,108 +1,123 @@
import { Link, useParams } from 'react-router-dom';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import Lottie from 'lottie-react';
import { Badge } from 'components/shared/Badge';
import { Button } from 'components/shared/Button';
import {
ArrowLeftCircleFilledIcon,
LinkChainIcon,
QuestionMarkRoundFilledIcon,
} from 'components/shared/CustomIcon';
import { Heading } from 'components/shared/Heading';
import logoAnimation from 'components/../../public/lottie/logo.json';
import { useGQLClient } from 'context/GQLClientContext';
import { Project } from 'gql-client';
import { useEffect, useState } from 'react';
const Id = () => {
const { id, orgSlug } = useParams();
const client = useGQLClient();
const [project, setProject] = useState<Project | null>(null);
const [searchParams] = useSearchParams();
const isAuction = searchParams.get('isAuction') === 'true';
const handleSetupDomain = () => {
//TODO: Implement this
const handleSetupDomain = async () => {
if (id) {
const project = await client.getProject(id);
if (project && project.project) {
setProject(project.project);
}
} else {
window.location.href = '/';
}
};
useEffect(() => {
handleSetupDomain();
}, []);
return (
<div className="flex flex-col gap-8 lg:gap-11 max-w-[522px] mx-auto py-6 lg:py-12">
{/* Icon */}
<div className="flex justify-center">
<Lottie animationData={logoAnimation} loop={false} size={40} />
</div>
{/* Heading */}
<div className="flex flex-col items-center gap-1.5">
<Heading as="h3" className="font-medium text-xl">
Project deployed successfully.
</Heading>
<p className="flex flex-col items-center lg:flex-row font-sans gap-0.5 lg:gap-2 text-sm text-elements-high-em">
Your project has been deployed at{' '}
<Button
className="no-underline text-elements-link"
// TODO: use dynamic value
href="https://www.iglootools.snowballtools.xyz"
as="a"
variant="link-emphasized"
external
leftIcon={<LinkChainIcon />}
>
{/* // TODO: use dynamic value */}
www.iglootools.snowballtools.xyz
</Button>
</p>
</div>
{/* Card */}
<div className="bg-base-bg-alternate rounded-xl shadow-inset w-full px-1 py-1">
{/* Trigger question */}
<div className="flex gap-2 justify-center items-center py-3">
<div className="h-5 w-5">
<QuestionMarkRoundFilledIcon size={18} />
<>
{project ? (
<div className="flex flex-col gap-8 lg:gap-11 max-w-[522px] mx-auto py-6 lg:py-12">
{/* Icon */}
<div className="flex justify-center">
<Lottie animationData={logoAnimation} loop={false} size={40} />
</div>
<Heading as="h5" className="font-sans font-medium text-sm">
{`Wondering what's next?`}
</Heading>
</div>
{/* CTA card */}
<div className="bg-surface-card rounded-xl shadow-card-sm px-4 py-4">
<div className="flex gap-2">
<Badge variant="secondary">1</Badge>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<Heading as="h6" className="text-sm font-sans">
Add a custom domain
</Heading>
<p className="text-xs text-elements-low-em font-sans">
Make it easy for your visitors to remember your URL with a
custom domain.
</p>
{/* Heading */}
<div className="flex flex-col items-center gap-1.5">
<Heading as="h3" className="font-medium text-xl">
{isAuction
? 'Auction created successfully.'
: 'Project deployment created successfully.'}
</Heading>
</div>
{/* Card */}
<div className="bg-base-bg-alternate rounded-xl shadow-inset w-full px-1 py-1">
{/* Trigger question */}
<div className="flex gap-2 justify-center items-center py-3">
<div className="h-5 w-5">
<QuestionMarkRoundFilledIcon size={18} />
</div>
<Button onClick={handleSetupDomain} variant="tertiary" size="sm">
Setup domain
</Button>
<Heading as="h5" className="font-sans font-medium text-sm">
{`Wondering what's next?`}
</Heading>
</div>
{/* CTA card */}
<div className="bg-surface-card rounded-xl shadow-card-sm px-4 py-4">
<div className="flex gap-2">
<Badge variant="secondary">1</Badge>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<Heading as="h6" className="text-sm font-sans">
Add a custom domain
</Heading>
<p className="text-xs text-elements-low-em font-sans">
Make it easy for your visitors to remember your URL with a
custom domain.
</p>
</div>
<Button
onClick={handleSetupDomain}
variant="tertiary"
size="sm"
>
Setup domain
</Button>
</div>
</div>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col lg:flex-row justify-center gap-3">
<div className="w-full lg:w-fit">
<Link to="/">
<Button
leftIcon={<ArrowLeftCircleFilledIcon />}
fullWidth
variant="tertiary"
>
Back to projects
</Button>
</Link>
</div>
<div className="w-full lg:w-fit">
<Link to={`/${orgSlug}/projects/${id}`}>
<Button fullWidth variant="primary">
View project
</Button>
</Link>
</div>
</div>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col lg:flex-row justify-center gap-3">
<div className="w-full lg:w-fit">
<Link to="/">
<Button
leftIcon={<ArrowLeftCircleFilledIcon />}
fullWidth
variant="tertiary"
>
Back to projects
</Button>
</Link>
</div>
<div className="w-full lg:w-fit">
<Link to={`/${orgSlug}/projects/${id}`}>
<Button fullWidth variant="primary">
View project
</Button>
</Link>
</div>
</div>
</div>
) : (
<></>
)}
</>
);
};

View File

@ -0,0 +1,7 @@
import ConfigureComponent from '../../../../../components/projects/create/Configure';
const Configure = () => {
return <ConfigureComponent />;
};
export default Configure;

View File

@ -6,13 +6,14 @@ import { useMediaQuery } from 'usehooks-ts';
import { RequestError } from 'octokit';
import { useOctokit } from '../../../../../context/OctokitContext';
import { useGQLClient } from '../../../../../context/GQLClientContext';
import { Template } from '../../../../../types/types';
import { Heading } from 'components/shared/Heading';
import { Input } from 'components/shared/Input';
import { Select, SelectOption } from 'components/shared/Select';
import { ArrowRightCircleFilledIcon } from 'components/shared/CustomIcon';
import { Checkbox } from 'components/shared/Checkbox';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
@ -28,7 +29,6 @@ type SubmitRepoValues = {
const CreateRepo = () => {
const { octokit, isAuth } = useOctokit();
const { template } = useOutletContext<{ template: Template }>();
const client = useGQLClient();
const { orgSlug } = useParams();
const { toast, dismiss } = useToast();
@ -41,6 +41,17 @@ const CreateRepo = () => {
const [gitAccounts, setGitAccounts] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const checkRepoExists = async (account: string, repoName: string) => {
try {
await octokit.rest.repos.get({ owner: account, repo: repoName });
return true;
} catch (error) {
// Error handled by octokit error hook interceptor in Octokit context
console.error(error);
return;
}
};
const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback(
async (data) => {
assert(data.account);
@ -50,36 +61,23 @@ const CreateRepo = () => {
assert(template.repoFullName, 'Template URL not provided');
const [owner, repo] = template.repoFullName.split('/');
// TODO: Handle this functionality in backend
const gitRepo = await octokit?.rest.repos.createUsingTemplate({
template_owner: owner,
template_repo: repo,
owner: data.account,
name: data.repoName,
include_all_branches: false,
private: data.isPrivate,
});
if (!gitRepo) {
const repoExists = await checkRepoExists(data.account, data.repoName);
if (repoExists) {
toast({
id: 'repo-exist-error',
title: 'Repository already exists with this name',
variant: 'warning',
onDismiss: dismiss,
});
setIsLoading(false);
return;
}
// Refetch to always get correct default branch
const templateRepo = await octokit.rest.repos.get({
owner: template.repoFullName.split('/')[0],
repo: template.repoFullName.split('/')[1],
});
const prodBranch = templateRepo.data.default_branch ?? 'main';
setIsLoading(true);
const { addProject } = await client.addProject(orgSlug!, {
name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`,
prodBranch,
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
});
navigate(`deploy?projectId=${addProject.id}&templateId=${template.id}`);
navigate(
`configure?templateId=${template.id}&templateOwner=${owner}&templateRepo=${repo}&owner=${data.account}&name=${data.repoName}&isPrivate=false&orgSlug=${orgSlug}`,
);
} catch (err) {
setIsLoading(false);
@ -106,7 +104,7 @@ const CreateRepo = () => {
});
}
},
[octokit],
[octokit, toast],
);
useEffect(() => {
@ -187,28 +185,26 @@ const CreateRepo = () => {
<Controller
name="repoName"
control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input value={value} onChange={onChange} />
)}
/>
</div>
<div>
<Controller
name="isPrivate"
control={control}
render={({}) => (
<Checkbox label="Make this repo private" disabled={true} />
)}
/>
</div>
<div>
<Button
{...buttonSize}
type="submit"
disabled={!Boolean(template.repoFullName) || isLoading}
rightIcon={<ArrowRightCircleFilledIcon />}
rightIcon={
isLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
Deploy
Next
</Button>
</div>
</div>

View File

@ -1,4 +1,5 @@
import CreateRepo from './index';
import Configure from './Configure';
import Deploy from './Deploy';
export const templateRoutes = [
@ -6,6 +7,10 @@ export const templateRoutes = [
index: true,
element: <CreateRepo />,
},
{
path: 'configure',
element: <Configure />,
},
{
path: 'deploy',
element: <Deploy />,

View File

@ -1,14 +1,11 @@
import { useEffect, useState } from 'react';
import { Domain, DomainStatus } from 'gql-client';
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { RequestError } from 'octokit';
import { useOctokit } from '../../../../context/OctokitContext';
import { useOctokit } from 'context/OctokitContext';
import { GitCommitWithBranch, OutletContextType } from '../../../../types';
import { useGQLClient } from '../../../../context/GQLClientContext';
import { Button } from 'components/shared/Button';
import { Heading } from 'components/shared/Heading';
import { Avatar } from 'components/shared/Avatar';
import { useGQLClient } from 'context/GQLClientContext';
import { Button, Heading, Avatar, Tag } from 'components/shared';
import { getInitials } from 'utils/geInitials';
import {
BranchStrokeIcon,
@ -18,12 +15,13 @@ import {
GithubStrokeIcon,
GlobeIcon,
LinkIcon,
CalendarDaysIcon,
} from 'components/shared/CustomIcon';
import { Tag } from 'components/shared/Tag';
import { Activity } from 'components/projects/project/overview/Activity';
import { OverviewInfo } from 'components/projects/project/overview/OverviewInfo';
import { CalendarDaysIcon } from 'components/shared/CustomIcon/CalendarDaysIcon';
import { relativeTimeMs } from 'utils/time';
import { Domain, DomainStatus } from 'gql-client';
import { AuctionCard } from 'components/projects/project/overview/Activity/AuctionCard';
const COMMITS_PER_PAGE = 4;
@ -131,9 +129,19 @@ const OverviewTabPanel = () => {
<Heading className="text-lg leading-6 font-medium truncate">
{project.name}
</Heading>
<p className="text-sm text-elements-low-em tracking-tight truncate">
{project.subDomain}
</p>
{project.deployments &&
project.deployments.length > 0 &&
project.deployments.map((deployment, index) => (
<p>
<a
key={index}
href={`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}
className="text-sm text-elements-low-em tracking-tight truncate"
>
{deployment.deployer.baseDomain}
</a>
</p>
))}
</div>
</div>
<OverviewInfo label="Domain" icon={<GlobeIcon />}>
@ -172,14 +180,18 @@ const OverviewTabPanel = () => {
{/* DEPLOYMENT */}
<OverviewInfo label="Deployment URL" icon={<CursorBoxIcon />}>
{project.deployments &&
project.deployments.length > 0 &&
project.deployments.map((deployment) => (
<div className="flex gap-2 items-center">
<Link to="#">
<Link to={`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}>
<span className="text-controls-primary group hover:border-controls-primary transition-colors border-b border-b-transparent flex gap-2 items-center text-sm tracking-tight">
{liveDomain?.name}{' '}
{`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}
<LinkIcon className="group-hover:rotate-45 transition-transform" />
</span>
</Link>
</div>
))}
</OverviewInfo>
{/* DEPLOYMENT DATE */}
@ -205,6 +217,7 @@ const OverviewTabPanel = () => {
No current deployment found.
</p>
)}
{project.auctionId && <AuctionCard project={project} />}
</div>
<Activity activities={activities} isLoading={fetchingActivities} />
</div>

View File

@ -1,38 +1,33 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Environment, EnvironmentVariable } from 'gql-client';
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
import AddEnvironmentVariableRow from 'components/projects/project/settings/AddEnvironmentVariableRow';
import DisplayEnvironmentVariables from 'components/projects/project/settings/DisplayEnvironmentVariables';
import { useGQLClient } from '../../../../../context/GQLClientContext';
import { useGQLClient } from 'context/GQLClientContext';
import { EnvironmentVariablesFormValues } from '../../../../../types';
import HorizontalLine from 'components/HorizontalLine';
import { Heading } from 'components/shared/Heading';
import { Button } from 'components/shared/Button';
import { Checkbox } from 'components/shared/Checkbox';
import { PlusIcon } from 'components/shared/CustomIcon';
import { InlineNotification } from 'components/shared/InlineNotification';
import { ProjectSettingContainer } from 'components/projects/project/settings/ProjectSettingContainer';
import { useToast } from 'components/shared/Toast';
import { Environment, EnvironmentVariable } from 'gql-client';
import EnvironmentVariablesForm from './EnvironmentVariablesForm';
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
import { Button } from 'components/shared';
export const EnvironmentVariablesTabPanel = () => {
const { id } = useParams();
const client = useGQLClient();
const { toast, dismiss } = useToast();
const [environmentVariables, setEnvironmentVariables] = useState<
EnvironmentVariable[]
>([]);
const {
handleSubmit,
register,
control,
reset,
formState: { isSubmitSuccessful, errors },
} = useForm<EnvironmentVariablesFormValues>({
const [createNewVariable, setCreateNewVariable] = useState(false);
const methods = useForm<EnvironmentVariablesFormValues>({
defaultValues: {
variables: [{ key: '', value: '' }],
environment: {
@ -42,21 +37,6 @@ export const EnvironmentVariablesTabPanel = () => {
},
},
});
const [createNewVariable, setCreateNewVariable] = useState(false);
const { fields, append, remove } = useFieldArray({
name: 'variables',
control,
rules: {
required: 'Add at least 1 environment variables',
},
});
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset, id]);
const getEnvironmentVariables = useCallback(
(environment: Environment) => {
@ -67,21 +47,6 @@ export const EnvironmentVariablesTabPanel = () => {
[environmentVariables, id],
);
const isFieldEmpty = useMemo(() => {
if (errors.variables) {
return fields.some((_, index) => {
if (
errors.variables![index]?.value?.type === 'required' ||
errors.variables![index]?.key?.type === 'required'
) {
return true;
}
});
}
return false;
}, [fields, errors.variables, id]);
const fetchEnvironmentVariables = useCallback(
async (id: string | undefined) => {
if (id) {
@ -98,33 +63,47 @@ export const EnvironmentVariablesTabPanel = () => {
}, [id]);
const createEnvironmentVariablesHandler = useCallback(
async (createFormData: EnvironmentVariablesFormValues) => {
const environmentVariables = createFormData.variables.map((variable) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
});
async (createFormData: FieldValues) => {
const environmentVariables = createFormData.variables.map(
(variable: any) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
},
);
const { addEnvironmentVariables: isEnvironmentVariablesAdded } =
await client.addEnvironmentVariables(id!, environmentVariables);
if (isEnvironmentVariablesAdded) {
reset();
methods.reset();
setCreateNewVariable((cur) => !cur);
fetchEnvironmentVariables(id);
toast.success(
createFormData.variables.length > 1
? `${createFormData.variables.length} variables added`
: `Variable added`,
);
toast({
id:
createFormData.variables.length > 1
? 'env_variable_added'
: 'env_variables_added',
title:
createFormData.variables.length > 1
? `${createFormData.variables.length} variables added`
: `Variable added`,
variant: 'success',
onDismiss: dismiss,
});
} else {
toast.error('Environment variables not added');
toast({
id: 'env_variables_not_added',
title: 'Environment variables not added',
variant: 'error',
onDismiss: dismiss,
});
}
},
[id, client],
@ -146,59 +125,14 @@ export const EnvironmentVariablesTabPanel = () => {
</div>
</Heading>
<Collapse open={createNewVariable}>
<div className="p-4 bg-slate-100">
<form onSubmit={handleSubmit(createEnvironmentVariablesHandler)}>
{fields.map((field, index) => {
return (
<AddEnvironmentVariableRow
key={field.id}
index={index}
register={register}
onDelete={() => remove(index)}
isDeleteDisabled={fields.length === 1}
/>
);
})}
<div className="flex gap-1 p-2">
<Button
size="md"
onClick={() =>
append({
key: '',
value: '',
})
}
>
+ Add variable
</Button>
{/* TODO: Implement import environment varible functionality */}
<Button size="md" disabled>
Import .env
</Button>
</div>
{isFieldEmpty && (
<InlineNotification
title="Please ensure no fields are empty before saving."
variant="danger"
size="md"
/>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit((data) =>
createEnvironmentVariablesHandler(data),
)}
<div className="flex gap-2 p-2">
<Checkbox
label="Production"
{...register(`environment.production`)}
color="blue"
/>
<Checkbox
label="Preview"
{...register(`environment.preview`)}
color="blue"
/>
<Checkbox
label="Development"
{...register(`environment.development`)}
color="blue"
/>
>
<div className="p-4 bg-slate-100">
<EnvironmentVariablesForm />
</div>
<div className="p-2">
<Button size="md" type="submit">
@ -206,7 +140,7 @@ export const EnvironmentVariablesTabPanel = () => {
</Button>
</div>
</form>
</div>
</FormProvider>
</Collapse>
</div>
<div className="p-2">

View File

@ -0,0 +1,79 @@
import { useEffect, useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
// TODO: Use custom checkbox component
import { Checkbox } from '@snowballtools/material-tailwind-react-fork';
import { Button } from 'components/shared/Button';
import { InlineNotification } from 'components/shared/InlineNotification';
import AddEnvironmentVariableRow from 'components/projects/project/settings/AddEnvironmentVariableRow';
import { EnvironmentVariablesFormValues } from 'types/types';
const EnvironmentVariablesForm = () => {
const {
register,
control,
reset,
formState: { isSubmitSuccessful, errors },
} = useFormContext<EnvironmentVariablesFormValues>();
const { fields, append, remove } = useFieldArray({
name: 'variables',
control,
});
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset]);
const isFieldEmpty = useMemo(() => {
if (errors.variables) {
return fields.some((_, index) => {
if (
errors.variables![index]?.value?.type === 'required' ||
errors.variables![index]?.key?.type === 'required'
) {
return true;
}
});
}
return false;
}, [fields, errors.variables]);
return (
<>
{fields.map((field, index) => (
<AddEnvironmentVariableRow
key={field.id}
index={index}
register={register}
onDelete={() => remove(index)}
isDeleteDisabled={fields.length === 0}
/>
))}
<div className="flex gap-1 p-2">
<Button size="md" onClick={() => append({ key: '', value: '' })}>
+ Add variable
</Button>
</div>
{isFieldEmpty && (
<InlineNotification
title="Please ensure no fields are empty before saving."
variant="danger"
/>
)}
<div className="flex gap-2 p-2">
<Checkbox label="Production" {...register('environment.production')} />
<Checkbox label="Preview" {...register('environment.preview')} />
<Checkbox
label="Development"
{...register('environment.development')}
/>
</div>
</>
);
};
export default EnvironmentVariablesForm;

View File

@ -1,12 +1,12 @@
import toast from 'react-hot-toast';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useGQLClient } from '../../../../../../../context/GQLClientContext';
import { useGQLClient } from 'context/GQLClientContext';
import { Table } from 'components/shared/Table';
import { Button } from 'components/shared/Button';
import { InlineNotification } from 'components/shared/InlineNotification';
import { ArrowRightCircleIcon } from 'components/shared/CustomIcon';
import { ProjectSettingContainer } from 'components/projects/project/settings/ProjectSettingContainer';
import { useToast } from 'components/shared/Toast';
const Config = () => {
const { id, orgSlug } = useParams();
@ -14,15 +14,26 @@ const Config = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const primaryDomainName = searchParams.get('name');
const { toast, dismiss } = useToast();
const handleSubmitDomain = async () => {
if (primaryDomainName === null) {
toast.error('Cannot resolve domain name');
toast({
id: 'unresolvable_domain_name',
title: 'Cannot resolve domain name',
variant: 'error',
onDismiss: dismiss,
});
return;
}
if (id === undefined) {
toast.error('Cannot find project');
toast({
id: 'domain_cannot_find_project',
title: 'Cannot find project',
variant: 'error',
onDismiss: dismiss,
});
return;
}
@ -31,10 +42,20 @@ const Config = () => {
});
if (isAdded) {
toast.success('Domain added successfully');
toast({
id: 'domain_added_successfully',
title: 'Domain added successfully',
variant: 'success',
onDismiss: dismiss,
});
navigate(`/${orgSlug}/projects/${id}/settings/domains`);
} else {
toast.error('Error adding domain');
toast({
id: 'generic_error_adding_domain',
title: 'Error adding domaint',
variant: 'error',
onDismiss: dismiss,
});
}
};

View File

@ -6,9 +6,13 @@ const meta: Meta<typeof ChevronDownSmallIcon> = {
title: 'Icons/ChevronDownSmallIcon',
component: ChevronDownSmallIcon,
tags: ['autodocs'],
args: {
size: 'string | number' as unknown as any,
name: 'string',
argTypes: {
size: {
control: 'text',
},
name: {
control: 'text',
},
},
};

View File

@ -6,9 +6,13 @@ const meta: Meta<typeof ChevronRight> = {
title: 'Icons/ChevronRight',
component: ChevronRight,
tags: ['autodocs'],
args: {
size: 'string | number' as unknown as any,
name: 'string',
argTypes: {
size: {
control: 'text',
},
name: {
control: 'text',
},
},
};

View File

@ -6,9 +6,13 @@ const meta: Meta<typeof ChevronUpDown> = {
title: 'Icons/ChevronUpDown',
component: ChevronUpDown,
tags: ['autodocs'],
args: {
size: 'string | number' as unknown as any,
name: 'string',
argTypes: {
size: {
control: 'text',
},
name: {
control: 'text',
},
},
};

View File

@ -3,12 +3,16 @@ import { Meta, StoryObj } from '@storybook/react';
import { ChevronUpSmallIcon } from 'components/shared/CustomIcon';
const meta: Meta<typeof ChevronUpSmallIcon> = {
title: 'Components/ChevronUpSmallIcon',
title: 'Icons/ChevronUpSmallIcon',
component: ChevronUpSmallIcon,
tags: ['autodocs'],
args: {
size: 'string | number' as unknown as any,
name: 'string',
argTypes: {
size: {
control: 'text',
},
name: {
control: 'text',
},
},
};

Some files were not shown because too many files have changed in this diff Show More