diff --git a/pkg/cloud/secrets/secret.go b/pkg/cloud/secrets/secret.go index 7790ac326..acb651988 100644 --- a/pkg/cloud/secrets/secret.go +++ b/pkg/cloud/secrets/secret.go @@ -21,9 +21,13 @@ import ( "context" "encoding/base64" "fmt" + "io" "os" "path/filepath" + "sort" "strings" + "sync" + "unicode/utf8" "github.com/google/uuid" "google.golang.org/grpc/codes" @@ -35,6 +39,7 @@ import ( type DevSecretService struct { secDir string + mu sync.RWMutex } var _ secretspb.SecretManagerServer = (*DevSecretService)(nil) @@ -75,6 +80,7 @@ func (s *DevSecretService) Put(ctx context.Context, req *secretspb.SecretPutRequ } writer.Flush() + file.Close() // Creates a new file as latest latestFile, err := os.Create(s.secretFileName(req.Secret, "latest")) @@ -98,6 +104,7 @@ func (s *DevSecretService) Put(ctx context.Context, req *secretspb.SecretPutRequ } latestWriter.Flush() + latestFile.Close() return &secretspb.SecretPutResponse{ SecretVersion: &secretspb.SecretVersion{ @@ -108,6 +115,9 @@ func (s *DevSecretService) Put(ctx context.Context, req *secretspb.SecretPutRequ } func (s *DevSecretService) Access(ctx context.Context, req *secretspb.SecretAccessRequest) (*secretspb.SecretAccessResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + newErr := grpc_errors.ErrorsWithScope( "DevSecretService.Access", ) @@ -151,6 +161,220 @@ func (s *DevSecretService) Access(ctx context.Context, req *secretspb.SecretAcce }, nil } +type SecretVersion struct { + Version string `json:"version"` + Value string `json:"value"` + Latest bool `json:"latest"` + CreatedAt string `json:"createdAt"` +} + +// formatUint8Array formats a byte array as a hexadecimal string with a space between each byte. +func formatUint8Array(byteArray []byte) string { + result := "" + + for i, b := range byteArray { + // Convert non-printable byte to a two-digit hexadecimal string + hex := fmt.Sprintf("%02x", b) + result += hex + " " + + // Add a new line every 16 bytes for the grid format + if (i+1)%16 == 0 { + result += "\n" + } + } + + return strings.ToUpper(result) +} + +// List all secret versions and values for a given secret, used by dashboard +func (s *DevSecretService) List(ctx context.Context, secretName string) ([]SecretVersion, error) { + newErr := grpc_errors.ErrorsWithScope( + "DevSecretService.List", + ) + + // Check whether file exists + _, err := os.Stat(s.secDir) + if os.IsNotExist(err) { + return nil, newErr(codes.NotFound, "secret store not found", err) + } + + // List all files in the directory + files, err := os.ReadDir(s.secDir) + if err != nil { + return nil, newErr(codes.FailedPrecondition, "error reading secret store", err) + } + + // Create a response + resp := []SecretVersion{} + + var latestVersion SecretVersion + + for _, file := range files { + // Check whether the file is a secret file + if strings.HasSuffix(file.Name(), ".txt") { + // Split the file name to get the secret name and version + splitName := strings.Split(file.Name(), "_") + // Check whether the secret name matches the requested secret + if splitName[0] == secretName { + version := strings.TrimSuffix(splitName[1], ".txt") + + info, err := file.Info() + if err != nil { + return nil, newErr(codes.FailedPrecondition, "error reading file info", err) + } + + createdAt := info.ModTime().Format("2006-01-02 15:04:05") + + valueResp, err := s.Access(ctx, &secretspb.SecretAccessRequest{ + SecretVersion: &secretspb.SecretVersion{ + Secret: &secretspb.Secret{Name: secretName}, + Version: version, + }, + }) + if err != nil { + // check if not found and add blank value + if strings.HasPrefix(err.Error(), "rpc error: code = NotFound desc") { + resp = append(resp, SecretVersion{ + Version: version, + Value: "", + CreatedAt: createdAt, + }) + + continue + } + + return nil, newErr(codes.FailedPrecondition, "error reading version value", err) + } + + var value string + + if utf8.Valid(valueResp.Value) { + value = string(valueResp.Value) + } else { + value = formatUint8Array(valueResp.Value) + } + + // Check whether the version is the latest + if version == "latest" { + latestVersion = SecretVersion{ + Value: value, + } + + continue + } + + // Add the secret to the response + resp = append(resp, SecretVersion{ + Version: version, + Value: value, + CreatedAt: createdAt, + }) + } + } + } + + if len(resp) > 0 { + // sort by created at + sort.Slice(resp, func(i, j int) bool { + return resp[i].CreatedAt > resp[j].CreatedAt + }) + + // mark latest version + if resp[0].Value == latestVersion.Value { + resp[0].Latest = true + } + } + + return resp, nil +} + +// Delete a secret version, used by dashboard +func (s *DevSecretService) Delete(ctx context.Context, secretName string, version string, latest bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + newErr := grpc_errors.ErrorsWithScope( + "DevSecretService.Delete", + ) + + // Check whether file exists + _, err := os.Stat(s.secDir) + if os.IsNotExist(err) { + return newErr(codes.NotFound, "secret store not found", err) + } + + // delete the version file + err = os.Remove(s.secretFileName(&secretspb.Secret{Name: secretName}, version)) + if err != nil { + return newErr(codes.Internal, "error deleting secret version", err) + } + + if latest { + // delete the latest file + err = os.Remove(s.secretFileName(&secretspb.Secret{Name: secretName}, "latest")) + if err != nil { + return newErr(codes.Internal, "error deleting latest secret version", err) + } + + // get last latest version and create a new latest + entries, err := os.ReadDir(s.secDir) + if err != nil { + return newErr(codes.FailedPrecondition, "error reading secret store", err) + } + + var files []os.FileInfo + + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return newErr(codes.FailedPrecondition, "error reading file info", err) + } + + files = append(files, info) + } + + // sort files by date + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime().Before(files[j].ModTime()) + }) + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".txt") { + splitName := strings.Split(file.Name(), "_") + if splitName[0] == secretName { + version := strings.TrimSuffix(splitName[1], ".txt") + + // copy file as new latest file with same contents + destinationFile, err := os.Create(s.secretFileName(&secretspb.Secret{Name: secretName}, "latest")) + if err != nil { + return newErr(codes.FailedPrecondition, "error creating latest secret version", err) + } + + sourceFile, err := os.Open(s.secretFileName(&secretspb.Secret{Name: secretName}, version)) + if err != nil { + return newErr(codes.FailedPrecondition, "error reading secret version", err) + } + + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + return newErr(codes.FailedPrecondition, "error copying secret version", err) + } + + err = destinationFile.Sync() + if err != nil { + return newErr(codes.FailedPrecondition, "error syncing latest secret version", err) + } + + sourceFile.Close() + destinationFile.Close() + } + } + } + } + + return nil +} + // Create new secret store func NewSecretService() (*DevSecretService, error) { secDir := env.LOCAL_SECRETS_DIR.String() diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index 89eb38950..dec2c3d65 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -48,6 +48,7 @@ import ( httpproxy "github.com/nitrictech/cli/pkg/cloud/http" "github.com/nitrictech/cli/pkg/cloud/resources" "github.com/nitrictech/cli/pkg/cloud/schedules" + "github.com/nitrictech/cli/pkg/cloud/secrets" "github.com/nitrictech/cli/pkg/cloud/sql" "github.com/nitrictech/cli/pkg/cloud/storage" "github.com/nitrictech/cli/pkg/cloud/topics" @@ -103,6 +104,10 @@ type SQLDatabaseSpec struct { ConnectionString string `json:"connectionString"` } +type SecretSpec struct { + *BaseResourceSpec +} + type NotifierSpec struct { Bucket string `json:"bucket"` Target string `json:"target"` @@ -143,6 +148,7 @@ type Dashboard struct { storageService *storage.LocalStorageService gatewayService *gateway.LocalGatewayService databaseService *sql.LocalSqlServer + secretService *secrets.DevSecretService apis []ApiSpec apiUseHttps bool apiSecurityDefinitions map[string]map[string]*resourcespb.ApiSecurityDefinitionResource @@ -150,6 +156,7 @@ type Dashboard struct { topics []*TopicSpec buckets []*BucketSpec stores []*KeyValueSpec + secrets []*SecretSpec sqlDatabases []*SQLDatabaseSpec websockets []WebsocketSpec subscriptions []*SubscriberSpec @@ -181,6 +188,7 @@ type DashboardResponse struct { Notifications []*NotifierSpec `json:"notifications"` Stores []*KeyValueSpec `json:"stores"` SQLDatabases []*SQLDatabaseSpec `json:"sqlDatabases"` + Secrets []*SecretSpec `json:"secrets"` Queues []*QueueSpec `json:"queues"` HttpProxies []*HttpProxySpec `json:"httpProxies"` @@ -196,7 +204,6 @@ type DashboardResponse struct { CurrentVersion string `json:"currentVersion"` LatestVersion string `json:"latestVersion"` Connected bool `json:"connected"` - DashboardAddress string `json:"dashboardAddress"` } type Bucket struct { @@ -312,6 +319,27 @@ func (d *Dashboard) updateResources(lrs resources.LocalResourcesState) { }) } + for secretName, resource := range lrs.Secrets.GetAll() { + exists := lo.ContainsBy(d.secrets, func(item *SecretSpec) bool { + return item.Name == secretName + }) + + if !exists { + d.secrets = append(d.secrets, &SecretSpec{ + BaseResourceSpec: &BaseResourceSpec{ + Name: secretName, + RequestingServices: resource.RequestingServices, + }, + }) + } + } + + if len(d.secrets) > 0 { + slices.SortFunc(d.secrets, func(a, b *SecretSpec) int { + return compare(a.Name, b.Name) + }) + } + for policyName, policy := range lrs.Policies.GetAll() { d.policies[policyName] = PolicySpec{ BaseResourceSpec: &BaseResourceSpec{ @@ -557,8 +585,9 @@ func (d *Dashboard) isConnected() bool { proxiesRegistered := len(d.httpProxies) > 0 storesRegistered := len(d.stores) > 0 sqlRegistered := len(d.sqlDatabases) > 0 + secretsRegistered := len(d.secrets) > 0 - return apisRegistered || websocketsRegistered || topicsRegistered || schedulesRegistered || notificationsRegistered || proxiesRegistered || storesRegistered || sqlRegistered + return apisRegistered || websocketsRegistered || topicsRegistered || schedulesRegistered || notificationsRegistered || proxiesRegistered || storesRegistered || sqlRegistered || secretsRegistered } func (d *Dashboard) Start() error { @@ -629,6 +658,8 @@ func (d *Dashboard) Start() error { http.HandleFunc("/api/sql", d.createSqlQueryHandler()) + http.HandleFunc("/api/secrets", d.createSecretsHandler()) + // handle websockets http.HandleFunc("/ws-info", func(w http.ResponseWriter, r *http.Request) { err := d.wsWebSocket.HandleRequest(w, r) @@ -720,6 +751,7 @@ func (d *Dashboard) sendStackUpdate() error { Websockets: d.websockets, Policies: d.policies, Queues: d.queues, + Secrets: d.secrets, Services: services, Subscriptions: d.subscriptions, Notifications: d.notifications, @@ -730,10 +762,9 @@ func (d *Dashboard) sendStackUpdate() error { HttpWorkerAddresses: d.gatewayService.GetHttpWorkerAddresses(), TriggerAddress: d.gatewayService.GetTriggerAddress(), // StorageAddress: d.storageService.GetStorageEndpoint(), - CurrentVersion: currentVersion, - LatestVersion: latestVersion, - Connected: d.isConnected(), - DashboardAddress: d.GetDashboardUrl(), + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + Connected: d.isConnected(), } // Encode the response as JSON @@ -790,6 +821,7 @@ func New(noBrowser bool, localCloud *cloud.LocalCloud, project *project.Project) storageService: localCloud.Storage, gatewayService: localCloud.Gateway, databaseService: localCloud.Databases, + secretService: localCloud.Secrets, apis: []ApiSpec{}, apiUseHttps: localCloud.Gateway.ApiTlsCredentials != nil, apiSecurityDefinitions: map[string]map[string]*resourcespb.ApiSecurityDefinitionResource{}, @@ -805,6 +837,7 @@ func New(noBrowser bool, localCloud *cloud.LocalCloud, project *project.Project) websockets: []WebsocketSpec{}, stores: []*KeyValueSpec{}, sqlDatabases: []*SQLDatabaseSpec{}, + secrets: []*SecretSpec{}, queues: []*QueueSpec{}, httpProxies: []*HttpProxySpec{}, policies: map[string]PolicySpec{}, diff --git a/pkg/dashboard/frontend/.eslintrc.cjs b/pkg/dashboard/frontend/.eslintrc.cjs index c4b17ffe3..758711b71 100644 --- a/pkg/dashboard/frontend/.eslintrc.cjs +++ b/pkg/dashboard/frontend/.eslintrc.cjs @@ -20,6 +20,7 @@ module.exports = { rules: { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/triple-slash-reference': 'off', + '@typescript-eslint/no-empty-function': 'off', 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', 'jsx-a11y/media-has-caption': 'off', diff --git a/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts b/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts index e8e376d29..15d5941bb 100644 --- a/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts @@ -4,6 +4,7 @@ describe('a11y test suite', () => { '/schedules', '/storage', '/databases', + '/secrets', '/topics', '/not-found', '/architecture', diff --git a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts index d4652ec4f..f10970c9d 100644 --- a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts @@ -16,6 +16,9 @@ const expectedNodes = [ 'my-second-db', 'services/my-test-service.ts', 'services/my-test-db.ts', + 'services/my-test-secret.ts', + 'my-first-secret', + 'my-second-secret', ] describe('Architecture Spec', () => { diff --git a/pkg/dashboard/frontend/cypress/e2e/schedules.cy.ts b/pkg/dashboard/frontend/cypress/e2e/schedules.cy.ts index 49ebc930d..bc335eaeb 100644 --- a/pkg/dashboard/frontend/cypress/e2e/schedules.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/schedules.cy.ts @@ -19,7 +19,7 @@ describe('Schedules Spec', () => { cy.getTestEl('generated-request-path').should( 'have.text', - `http://localhost:4001/schedules/${schedule}`, + `http://localhost:4002/schedules/${schedule}`, ) cy.getTestEl('trigger-schedules-btn').click() diff --git a/pkg/dashboard/frontend/cypress/e2e/secrets.cy.ts b/pkg/dashboard/frontend/cypress/e2e/secrets.cy.ts new file mode 100644 index 000000000..f931b860b --- /dev/null +++ b/pkg/dashboard/frontend/cypress/e2e/secrets.cy.ts @@ -0,0 +1,191 @@ +describe('Secrets Spec', () => { + beforeEach(() => { + cy.viewport('macbook-16') + cy.visit('/secrets') + cy.wait(500) + }) + + it('should retrieve correct secrets', () => { + cy.get('h2').should('contain.text', 'my-first-secret') + + const expectedSecrets = ['my-first-secret', 'my-second-secret'] + + expectedSecrets.forEach((id) => { + cy.get(`[data-rct-item-id="${id}"]`).should('exist') + }) + }) + ;['my-first-secret', 'my-second-secret'].forEach((sec) => { + it(`should check for no versions ${sec}`, () => { + cy.get(`[data-rct-item-id="${sec}"]`).click() + + cy.get('.p-4 > .flex > .text-lg').should( + 'have.text', + 'No versions found.', + ) + }) + + it(`should create latest version for ${sec}`, () => { + cy.get(`[data-rct-item-id="${sec}"]`).click() + + cy.getTestEl('create-new-version').click() + + cy.intercept({ + url: '/api/secrets?**', + method: 'POST', + }).as('secrets') + + cy.getTestEl('secret-value').type(`my-secret-value-${sec}`) + + cy.getTestEl('submit-secrets-dialog').click() + + cy.wait('@secrets') + + cy.wait(500) + + cy.getTestEl('data-table-cell-0_value').should( + 'have.text', + `my-secret-value-${sec}`, + ) + + cy.getTestEl('data-table-0-latest-badge').should('have.text', 'Latest') + }) + + it(`should create new latest version for ${sec}`, () => { + cy.get(`[data-rct-item-id="${sec}"]`).click() + + cy.getTestEl('create-new-version').click() + + cy.intercept({ + url: '/api/secrets?**', + method: 'POST', + }).as('secrets') + + cy.getTestEl('secret-value').type(`my-secret-value-${sec}-2`) + + cy.getTestEl('submit-secrets-dialog').click() + + cy.wait('@secrets') + + cy.wait(500) + + cy.getTestEl('data-table-cell-0_value').should( + 'have.text', + `my-secret-value-${sec}-2`, + ) + + cy.getTestEl('data-table-cell-1_value').should( + 'have.text', + `my-secret-value-${sec}`, + ) + + cy.getTestEl('data-table-0-latest-badge').should('have.text', 'Latest') + }) + + it(`should delete and replace latest ${sec}`, () => { + cy.get(`[data-rct-item-id="${sec}"]`).click() + + cy.get('[data-testid="data-table-cell-0_select"] > .peer').click({ + force: true, + }) + + cy.getTestEl('delete-selected-versions').click() + + cy.getTestEl('submit-secrets-dialog').click() + + cy.reload() + + cy.get(`[data-rct-item-id="${sec}"]`).click() + + cy.getTestEl('data-table-cell-0_value').should( + 'have.text', + `my-secret-value-${sec}`, + ) + + cy.getTestEl('data-table-0-latest-badge').should('have.text', 'Latest') + }) + }) + + it(`should retrieve and update value from sdk for my-first-secret`, () => { + cy.visit('/') + + cy.intercept('/api/call/**').as('apiCall') + + cy.get('[data-rct-item-id="my-secret-api"]').click() + + cy.get('[data-rct-item-id="my-secret-api-/get-GET"]').click() + + cy.getTestEl('send-api-btn').click() + + cy.wait('@apiCall') + + cy.getAPIResponseCodeEditor() + .invoke('text') + .then((text) => { + expect(text).to.equal('my-secret-value-my-first-secret') + }) + + cy.get('[data-rct-item-id="my-secret-api-/set-POST"]').click() + + cy.intercept('/api/call/**').as('apiCall') + + cy.getTestEl('Body-tab-btn').click() + + cy.getJSONCodeEditorElement() + .clear() + .invoke('html', '{ "my-secret-test": 12345 }') + + cy.getTestEl('send-api-btn').click() + + cy.wait('@apiCall') + + cy.get('[data-rct-item-id="my-secret-api-/get-GET"]').click() + + cy.getTestEl('send-api-btn').click() + + cy.wait('@apiCall') + + cy.getAPIResponseCodeEditor() + .invoke('text') + .then((text) => { + expect(JSON.parse(text)).to.deep.equal({ + 'my-secret-test': 12345, + }) + }) + }) + + it(`should have latest secret from sdk set call for my-first-secret`, () => { + cy.get(`[data-rct-item-id="my-first-secret"]`).click() + + cy.getTestEl('data-table-cell-0_value').should( + 'have.text', + '{"my-secret-test":12345}', + ) + + cy.getTestEl('data-table-0-latest-badge').should('have.text', 'Latest') + }) + + it(`should format Uint8Array secret correctly`, () => { + cy.visit('/') + + cy.intercept('/api/call/**').as('apiCall') + + cy.get('[data-rct-item-id="my-secret-api"]').click() + + cy.get('[data-rct-item-id="my-secret-api-/set-binary-POST"]').click() + + cy.getTestEl('send-api-btn').click() + + cy.wait('@apiCall') + + cy.visit('/secrets') + + cy.get(`[data-rct-item-id="my-first-secret"]`).click() + + cy.getTestEl('data-table-cell-0_value').should( + 'have.text', + '00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F \n10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F \n20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F \n30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F \n40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F \n50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F \n60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F \n70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F \n80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F \n90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F \nA0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF \nB0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF \nC0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF \nD0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF \nE0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF \nF0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF \n00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F \n10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F \n20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F \n30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F \n40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F \n50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F \n60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F \n70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F \n80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F \n90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F \nA0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF \nB0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF \nC0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF \nD0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF \nE0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF \nF0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF \n00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F \n10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F \n20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F \n30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F \n40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F \n50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F \n60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F \n70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F \n80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F \n90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F \nA0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF \nB0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF \nC0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF \nD0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF \nE0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF \nF0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF \n00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F \n10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F \n20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F \n30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F \n40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F \n50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F \n60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F \n70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F \n80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F \n90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F \nA0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF \nB0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF \nC0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF \nD0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF \nE0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF \nF0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF \n', + ) + + cy.getTestEl('data-table-0-latest-badge').should('have.text', 'Latest') + }) +}) diff --git a/pkg/dashboard/frontend/cypress/e2e/topics.cy.ts b/pkg/dashboard/frontend/cypress/e2e/topics.cy.ts index 10664a2c6..549736349 100644 --- a/pkg/dashboard/frontend/cypress/e2e/topics.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/topics.cy.ts @@ -19,7 +19,7 @@ describe('Topics Spec', () => { cy.getTestEl('generated-request-path').should( 'have.text', - `http://localhost:4001/topics/${topic}`, + `http://localhost:4002/topics/${topic}`, ) cy.getTestEl('trigger-topics-btn').click() diff --git a/pkg/dashboard/frontend/cypress/e2e/websockets.cy.ts b/pkg/dashboard/frontend/cypress/e2e/websockets.cy.ts index fc6492c2b..497a4b59a 100644 --- a/pkg/dashboard/frontend/cypress/e2e/websockets.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/websockets.cy.ts @@ -19,7 +19,7 @@ describe('Websockets Spec', () => { it('should have correct websocket url', () => { cy.getTestEl('generated-request-path', 5000).should( 'contain.text', - 'ws://localhost:400', + 'ws://localhost:40', ) }) @@ -32,7 +32,7 @@ describe('Websockets Spec', () => { cy.getTestEl('accordion-message-0').should( 'contain.text', - 'Connected to ws://localhost:400', + 'Connected to ws://localhost:40', ) cy.getTestEl('message-text-input').type('My awesome test message!') @@ -79,12 +79,12 @@ describe('Websockets Spec', () => { cy.getTestEl('accordion-message-0').should( 'contain.text', - 'Disconnected from ws://localhost:400', + 'Disconnected from ws://localhost:40', ) cy.getTestEl('accordion-message-1').should( 'contain.text', - 'Error connecting to ws://localhost:400', + 'Error connecting to ws://localhost:40', ) }) diff --git a/pkg/dashboard/frontend/package.json b/pkg/dashboard/frontend/package.json index d4243f5b7..cd84d69aa 100644 --- a/pkg/dashboard/frontend/package.json +++ b/pkg/dashboard/frontend/package.json @@ -18,7 +18,7 @@ "lint": "eslint ." }, "dependencies": { - "@astrojs/react": "^3.6.0", + "@astrojs/react": "^3.6.1", "@astrojs/tailwind": "^5.1.0", "@codemirror/lint": "^6.8.1", "@dagrejs/dagre": "^1.0.4", @@ -27,8 +27,9 @@ "@heroicons/react": "^2.1.1", "@prantlf/jsonlint": "^14.0.3", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1", - "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", @@ -40,11 +41,12 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "@tailwindcss/forms": "^0.5.7", + "@tanstack/react-table": "^8.20.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@uiw/codemirror-extensions-langs": "^4.23.0", "@uiw/react-codemirror": "^4.23.0", - "astro": "^4.12.2", + "astro": "^4.13.1", "astro-fathom": "^2.0.0", "chonky": "^2.3.2", "chonky-icon-fontawesome": "^2.3.2", diff --git a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx index e436416bd..4a67dde87 100644 --- a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx @@ -54,6 +54,7 @@ import { SelectValue, } from '../ui/select' import { Alert } from '../ui/alert' +import SectionCard from '../shared/SectionCard' const getTabCount = (rows: FieldRow[]) => { if (!rows) return 0 @@ -515,231 +516,220 @@ const APIExplorer = () => { )} -
+ -
-
-
-
-

- {currentTabName} -

-
- {currentTabName === 'Params' && ( -
    - {request.pathParams.length > 0 && ( -
  • -

    - Path Params -

    - { - setRequest((prev) => ({ - ...prev, - pathParams: rows, - })) - }} - /> -
  • - )} -
  • -

    - Query Params -

    - { - setRequest((prev) => ({ - ...prev, - queryParams: rows, - })) - }} - /> -
  • -
- )} - {currentTabName === 'Headers' && ( -
+ + {currentTabName === 'Params' && ( +
    + {request.pathParams.length > 0 && ( +
  • +

    + Path Params +

    { setRequest((prev) => ({ ...prev, - headers: rows, + pathParams: rows, })) }} /> -
+ )} - {currentTabName === 'Body' && ( -
- - {currentBodyTabName === 'JSON' && ( - { - setJSONBody(value) - }} - /> - )} - {currentBodyTabName === 'Binary' && ( -
-

- Binary File -

- - {fileToUpload && ( - - {fileToUpload.name} -{' '} - {formatFileSize(fileToUpload.size)} - - )} -
+
  • +

    + Query Params +

    + { + setRequest((prev) => ({ + ...prev, + queryParams: rows, + })) + }} + /> +
  • + + )} + {currentTabName === 'Headers' && ( +
    + { + setRequest((prev) => ({ + ...prev, + headers: rows, + })) + }} + /> +
    + )} + {currentTabName === 'Body' && ( +
    + + {currentBodyTabName === 'JSON' && ( + { + setJSONBody(value) + }} + /> + )} + {currentBodyTabName === 'Binary' && ( +
    +

    + Binary File +

    + + {fileToUpload && ( + + {fileToUpload.name} -{' '} + {formatFileSize(fileToUpload.size)} + )}
    )}
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - Response -

    - {callLoading && ( - - )} -
    -
    - {response?.status && ( - = 400 ? 'red' : 'green'} - > - Status: {response.status} - - )} - {response?.time && ( - - Time: {formatResponseTime(response.time)} - - )} - {typeof response?.size === 'number' && ( - - Size: {formatFileSize(response.size)} - - )} -
    - -
    - {response?.data ? ( -
    - - {responseTabIndex === 0 && ( - - )} - {responseTabIndex === 1 && ( -
    -
    - - - - - - - - - {Object.entries( - response.headers || {}, - ).map(([key, value]) => ( - - - - - ))} - -
    - Header - - Value -
    - {key} - - {value} -
    -
    -
    - )} + )} + + + + + {callLoading && ( + + )} +
    + {response?.status && ( + = 400 ? 'red' : 'green'} + > + Status: {response.status} + + )} + {response?.time && ( + + Time: {formatResponseTime(response.time)} + + )} + {typeof response?.size === 'number' && ( + + Size: {formatFileSize(response.size)} + + )} +
    + + } + > +
    + {response?.data ? ( +
    + + {responseTabIndex === 0 && ( + + )} + {responseTabIndex === 1 && ( +
    +
    + + + + + + + + + {Object.entries(response.headers || {}).map( + ([key, value]) => ( + + + + + ), + )} + +
    + Header + + Value +
    + {key} + + {value} +
    - ) : response ? ( - - No response data available for this request. - - ) : ( - - Send a request to get a response. - - )} -
    +
    + )}
    -
    + ) : response ? ( + + No response data available for this request. + + ) : ( + + Send a request to get a response. + + )}
    -
    +
    -
    -

    - Request History -

    + { method: request.method, }} /> -
    +
    ) : (
    diff --git a/pkg/dashboard/frontend/src/components/apis/APIHistory.tsx b/pkg/dashboard/frontend/src/components/apis/APIHistory.tsx index 73aa7b48a..9e04fb581 100644 --- a/pkg/dashboard/frontend/src/components/apis/APIHistory.tsx +++ b/pkg/dashboard/frontend/src/components/apis/APIHistory.tsx @@ -75,79 +75,74 @@ const ApiHistoryAccordionContent: React.FC = ({ const jsonTabs = [...tabs, { name: 'Payload' }] return ( -
    -
    - -
    - {tabIndex === 0 && ( - key && value) - .map(([key, value]) => [ - key.toLowerCase(), - value.join(', '), - ]), - }, - { - name: 'Response Headers', - rows: Object.entries(response.headers ?? []) - .filter(([key, value]) => key && value) - .map(([key, value]) => [key.toLowerCase(), value]), - }, - ]} - /> - )} - {tabIndex === 1 && ( -
    -
    -

    Response Data

    - -
    +
    + +
    + {tabIndex === 0 && ( + key && value) + .map(([key, value]) => [key.toLowerCase(), value.join(', ')]), + }, + { + name: 'Response Headers', + rows: Object.entries(response.headers ?? []) + .filter(([key, value]) => key && value) + .map(([key, value]) => [key.toLowerCase(), value]), + }, + ]} + /> + )} + {tabIndex === 1 && ( +
    +
    +

    Response Data

    +
    - )} - {tabIndex === 2 && ( -
    -
    -

    Request Body

    - -
    -
    - {request.queryParams && ( - key && value) - .map(({ key, value }) => [key, value]), - }, - ]} - /> +
    + )} + {tabIndex === 2 && ( +
    +
    +

    Request Body

    + + title="Request Body" + /> +
    +
    + {request.queryParams && ( + key && value) + .map(({ key, value }) => [key, value]), + }, + ]} + /> + )}
    - )} -
    +
    + )}
    ) diff --git a/pkg/dashboard/frontend/src/components/architecture/nodes/SecretNode.tsx b/pkg/dashboard/frontend/src/components/architecture/nodes/SecretNode.tsx new file mode 100644 index 000000000..67264e589 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/architecture/nodes/SecretNode.tsx @@ -0,0 +1,25 @@ +import { type ComponentType } from 'react' + +import type { Secret } from '@/types' +import type { NodeProps } from 'reactflow' +import NodeBase, { type NodeBaseData } from './NodeBase' + +export type SecretNodeData = NodeBaseData + +export const SecretNode: ComponentType> = (props) => { + const { data } = props + + return ( + + ) +} diff --git a/pkg/dashboard/frontend/src/components/architecture/styles.css b/pkg/dashboard/frontend/src/components/architecture/styles.css index b62f19750..127f48133 100644 --- a/pkg/dashboard/frontend/src/components/architecture/styles.css +++ b/pkg/dashboard/frontend/src/components/architecture/styles.css @@ -124,3 +124,10 @@ --nitric-node-to: #1e3a8a; /* Blue 900 */ --nitric-node-icon-color: #075985; /* Sky 800 */ } + +.react-flow__node-secret { + --nitric-node-from: #475569; /* Slate 600 */ + --nitric-node-via: #94a3b8; /* Slate 400 */ + --nitric-node-to: #334155; /* Slate 700 */ + --nitric-node-icon-color: #475569; /* Slate 600 */ +} diff --git a/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx b/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx index aad6692d5..2cf8fa6fa 100644 --- a/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx @@ -23,6 +23,7 @@ import { Button } from '../ui/button' import CodeEditor from '../apis/CodeEditor' import QueryResults from './QueryResults' import { useSqlMeta } from '@/lib/hooks/use-sql-meta' +import SectionCard from '../shared/SectionCard' interface QueryHistoryItem { query: string @@ -238,97 +239,69 @@ const DatabasesExplorer: React.FC = () => {
    -
    -
    -
    -
    -
    -

    - Connect -

    -
    - -
    - - {selectedDb.connectionString} - - - - - - -

    Copy Connection String

    -
    -
    -
    -
    -
    + +
    + + {selectedDb.connectionString} + + + + + + +

    Copy Connection String

    +
    +
    -
    -
    -
    -
    -
    -
    -

    - SQL Editor -

    -
    -
    - { - try { - setSql(payload) - } catch { - return - } - }} - /> - -
    -
    -

    - Results -

    -
    - -
    -
    - -
    -
    + + +
    + { + try { + setSql(payload) + } catch { + return + } + }} + /> + +
    +
    +

    + Results +

    + +
    +
    +
    -
    +
    ) : !hasData ? ( diff --git a/pkg/dashboard/frontend/src/components/events/EventsExplorer.tsx b/pkg/dashboard/frontend/src/components/events/EventsExplorer.tsx index a4a24aaa4..87c72889a 100644 --- a/pkg/dashboard/frontend/src/components/events/EventsExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/events/EventsExplorer.tsx @@ -32,6 +32,7 @@ import { SelectTrigger, SelectValue, } from '../ui/select' +import SectionCard from '../shared/SectionCard' interface Props { workerType: 'schedules' | 'topics' @@ -270,153 +271,146 @@ const EventsExplorer: React.FC = ({ workerType }) => {
    {workerType === 'topics' && ( -
    -
    -

    Payload

    -
    - { - try { - setBody(JSON.parse(payload)) - } catch { - return - } - }} - /> + +
    + { + try { + setBody(JSON.parse(payload)) + } catch { + return + } + }} + /> - -
    +
    -
    + )} -
    -
    -
    -
    -
    -

    - Response -

    - {callLoading && ( - - )} -
    -
    - {response?.status && ( - = 400 ? 'red' : 'green'} - > - Status: {response.status} - - )} - {response?.time && ( - - Time: {formatResponseTime(response.time)} - - )} - {typeof response?.size === 'number' && ( - - Size: {formatFileSize(response.size)} - - )} -
    - -
    - {response?.data ? ( -
    - - {responseTabIndex === 0 && ( - - )} - {responseTabIndex === 1 && ( -
    -
    - - - - - - - - - {Object.entries( - response.headers || {}, - ).map(([key, value]) => ( - - - - - ))} - -
    - Header - - Value -
    - {key} - - {value} -
    -
    -
    - )} + + {callLoading && ( + + )} +
    + {response?.status && ( + = 400 ? 'red' : 'green'} + > + Status: {response.status} + + )} + {response?.time && ( + + Time: {formatResponseTime(response.time)} + + )} + {typeof response?.size === 'number' && ( + + Size: {formatFileSize(response.size)} + + )} +
    + + } + > +
    + {response?.data ? ( +
    + + {responseTabIndex === 0 && ( + + )} + {responseTabIndex === 1 && ( +
    +
    + + + + + + + + + {Object.entries(response.headers || {}).map( + ([key, value]) => ( + + + + + ), + )} + +
    + Header + + Value +
    + {key} + + {value} +
    - ) : response ? ( - - No response data available for this request. - - ) : ( - - Send a request to get a response. - - )} -
    +
    + )}
    -
    + ) : response ? ( + + No response data available for this request. + + ) : ( + + Send a request to get a response. + + )}
    -
    +
    -
    -

    History

    + -
    +
    ) : !hasData ? (
    diff --git a/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx b/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx index 83fc37f94..b64cdc641 100644 --- a/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx +++ b/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx @@ -13,6 +13,7 @@ import { MapIcon, HeartIcon, CircleStackIcon, + LockClosedIcon, } from '@heroicons/react/24/outline' import { cn } from '@/lib/utils' import { useWebSocket } from '../../../lib/hooks/use-web-socket' @@ -25,7 +26,6 @@ import { Spinner } from '../../shared' import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover' import { Sheet, SheetContent, SheetTrigger } from '../../ui/sheet' import NavigationBar from './NavigationBar' -import { Separator } from '@/components/ui/separator' const DiscordLogo: React.FC> = ({ className, @@ -116,11 +116,6 @@ const AppLayout: React.FC = ({ href: '/databases', icon: CircleStackIcon, }, - { - name: 'Websockets', - href: '/websockets', - icon: ChatBubbleLeftRightIcon, - }, { name: 'Schedules', href: '/schedules', @@ -136,8 +131,17 @@ const AppLayout: React.FC = ({ href: '/topics', icon: MegaphoneIcon, }, + { + name: 'Secrets', + href: '/secrets', + icon: LockClosedIcon, + }, + { + name: 'Websockets', + href: '/websockets', + icon: ChatBubbleLeftRightIcon, + }, // { name: "Key Value Stores", href: "#", icon: FolderIcon, current: false }, - // { name: "Secrets", href: "#", icon: LockClosedIcon, current: false }, ] const showAlert = data?.connected === false || state === 'error' @@ -333,21 +337,23 @@ const AppLayout: React.FC = ({ Reach out to the community
    - {communityLinks.map((item) => ( - - - ))} + {communityLinks + .filter((item) => item.name !== 'Sponsor') + .map((item) => ( + + + ))}
    @@ -410,21 +416,23 @@ const AppLayout: React.FC = ({ Reach out to the community
    - {communityLinks.map((item) => ( - - - ))} + {communityLinks + .filter((item) => item.name !== 'Sponsor') + .map((item) => ( + + + ))}

    CLI Version: v{data?.currentVersion} diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx new file mode 100644 index 000000000..2837cacab --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx @@ -0,0 +1,85 @@ +import { DataTable } from '@/components/shared/DataTable' +import type { SecretVersion } from '@/types' +import { columns } from './columns' +import { Button } from '@/components/ui/button' +import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid' +import { VersionActionDialog } from '../VersionActionDialog' +import { useState } from 'react' +import { useSecretsContext } from '../SecretsContext' +import { useSecret } from '@/lib/hooks/use-secret' + +export const SecretVersionsTable = () => { + const { + selectedSecret, + setSelectedVersions, + setDialogAction, + setDialogOpen, + } = useSecretsContext() + const { data: secretVersions } = useSecret(selectedSecret?.name) + + if (!selectedSecret || !secretVersions) return null + + return ( + <> + { + const versions = + Object.keys(selected) + .map((s) => { + return secretVersions[parseInt(s)] + }) + .filter(Boolean) ?? [] + + return ( +

    + + + +
    + ) + }} + noResultsChildren={ +
    + No versions found.{' '} + +
    + } + /> + + ) +} diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/columns.tsx b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/columns.tsx new file mode 100644 index 000000000..135e0b9b4 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/columns.tsx @@ -0,0 +1,144 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { copyToClipboard } from '@/lib/utils/copy-to-clipboard' +import type { SecretVersion } from '@/types' +import { EllipsisHorizontalIcon } from '@heroicons/react/20/solid' +import { ArrowsUpDownIcon } from '@heroicons/react/24/outline' +import type { ColumnDef } from '@tanstack/react-table' +import { Checkbox } from '@/components/ui/checkbox' +import { useSecretsContext } from '../SecretsContext' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { ScrollArea } from '@/components/ui/scroll-area' + +export const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + }, + { + accessorKey: 'version', + header: 'Version', + cell: ({ row }) => { + const secretVersion = row.original + + return ( +
    + {secretVersion.version} + {secretVersion.latest && ( + + Latest + + )} +
    + ) + }, + }, + { + accessorKey: 'value', + header: 'Value', + cell: ({ row }) => { + const secretVersion = row.original + + return ( +
    + + +
    + {secretVersion.value} +
    +
    + + +
    {secretVersion.value}
    +
    +
    +
    +
    + ) + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => { + return ( + + ) + }, + }, + { + accessorKey: 'Actions', + cell: ({ row }) => { + const secretVersion = row.original + const { setDialogAction, setDialogOpen, setSelectedVersions } = + useSecretsContext() + + return ( + <> + + + + + + Actions + copyToClipboard(secretVersion.value)} + > + Copy secret value + + { + setSelectedVersions([secretVersion]) + setDialogAction('delete') + setDialogOpen(true) + }} + > + Delete + + + + + ) + }, + }, +] diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/index.ts b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/index.ts new file mode 100644 index 000000000..c01732dc6 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/index.ts @@ -0,0 +1,3 @@ +import { SecretVersionsTable } from './SecretVersionsTable' + +export default SecretVersionsTable diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretsContext.tsx b/pkg/dashboard/frontend/src/components/secrets/SecretsContext.tsx new file mode 100644 index 000000000..bb898e7f8 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretsContext.tsx @@ -0,0 +1,56 @@ +import { useSecret } from '@/lib/hooks/use-secret' +import type { Secret, SecretVersion } from '@/types' +import React, { createContext, useState, type PropsWithChildren } from 'react' +import { VersionActionDialog } from './VersionActionDialog' + +interface SecretsContextProps { + selectedVersions: SecretVersion[] + setSelectedVersions: React.Dispatch> + selectedSecret?: Secret + setSelectedSecret: (secret: Secret | undefined) => void + setDialogAction: React.Dispatch> + setDialogOpen: React.Dispatch> +} + +export const SecretsContext = createContext({ + selectedVersions: [], + setSelectedVersions: () => {}, + selectedSecret: undefined, + setSelectedSecret: () => {}, + setDialogAction: () => {}, + setDialogOpen: () => {}, +}) + +export const SecretsProvider: React.FC = ({ children }) => { + const [selectedSecret, setSelectedSecret] = useState() + + const [selectedVersions, setSelectedVersions] = useState([]) + const [dialogOpen, setDialogOpen] = useState(false) + const [dialogAction, setDialogAction] = useState<'add' | 'delete'>('add') + + return ( + + {selectedSecret && ( + + )} + {children} + + ) +} + +export const useSecretsContext = () => { + return React.useContext(SecretsContext) +} diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretsExplorer.tsx b/pkg/dashboard/frontend/src/components/secrets/SecretsExplorer.tsx new file mode 100644 index 000000000..96d383977 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretsExplorer.tsx @@ -0,0 +1,121 @@ +import { useWebSocket } from '../../lib/hooks/use-web-socket' +import type { Secret } from '@/types' +import { Loading } from '../shared' + +import AppLayout from '../layout/AppLayout' +import BreadCrumbs from '../layout/BreadCrumbs' +import SecretsTreeView from './SecretsTreeView' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select' +import { useEffect, useState } from 'react' +import { useSecret } from '@/lib/hooks/use-secret' +import SecretVersionsTable from './SecretVersionsTable' +import { SecretsProvider, useSecretsContext } from './SecretsContext' + +const SecretsExplorer: React.FC = () => { + const { data, loading } = useWebSocket() + + const { selectedSecret, setSelectedSecret } = useSecretsContext() + + useEffect(() => { + if (data && data.secrets.length) { + setSelectedSecret(data.secrets[0]) + } + }, [data]) + + const hasData = Boolean(data && data.secrets.length) + + return ( + +
    + Secrets +
    + + + ) + } + > + + {selectedSecret && hasData ? ( +
    +
    +
    +
    + {hasData && ( + + )} +
    +
    + + Secrets +

    + {selectedSecret.name} +

    +
    +
    + +
    +
    + ) : !hasData ? ( +
    + Please refer to our documentation on{' '} + + creating a secret + {' '} + as we are unable to find any existing secrets. +
    + ) : null} +
    +
    + ) +} + +export default function SecretsExplorerWrapped() { + return ( + + + + ) +} diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretsTreeView.tsx b/pkg/dashboard/frontend/src/components/secrets/SecretsTreeView.tsx new file mode 100644 index 000000000..ea7f2900a --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretsTreeView.tsx @@ -0,0 +1,66 @@ +import { type FC, useMemo } from 'react' +import type { Secret } from '@/types' +import TreeView, { type TreeItemType } from '../shared/TreeView' +import type { TreeItem, TreeItemIndex } from 'react-complex-tree' + +export type SecretsTreeItemType = TreeItemType + +interface Props { + resources: Secret[] + onSelect: (resource: Secret) => void + initialItem: Secret +} + +const SecretsTreeView: FC = ({ resources, onSelect, initialItem }) => { + const treeItems: Record< + TreeItemIndex, + TreeItem + > = useMemo(() => { + const rootItem: TreeItem = { + index: 'root', + isFolder: true, + children: [], + data: null, + } + + const rootItems: Record> = { + root: rootItem, + } + + for (const resource of resources) { + // add api if not added already + if (!rootItems[resource.name]) { + rootItems[resource.name] = { + index: resource.name, + data: { + label: resource.name, + data: resource, + }, + } + + rootItem.children!.push(resource.name) + } + } + + return rootItems + }, [resources]) + + return ( + + label={'Secrets'} + items={treeItems} + initialItem={initialItem.name} + getItemTitle={(item) => item.data.label} + onPrimaryAction={(items) => { + if (items.data.data) { + onSelect(items.data.data) + } + }} + renderItemTitle={({ item }) => { + return {item.data.label} + }} + /> + ) +} + +export default SecretsTreeView diff --git a/pkg/dashboard/frontend/src/components/secrets/VersionActionDialog.tsx b/pkg/dashboard/frontend/src/components/secrets/VersionActionDialog.tsx new file mode 100644 index 000000000..d2b42fd12 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/VersionActionDialog.tsx @@ -0,0 +1,129 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useSecret } from '@/lib/hooks/use-secret' +import { Loader2 } from 'lucide-react' +import { useRef, useState } from 'react' +import { useSecretsContext } from './SecretsContext' +import toast from 'react-hot-toast' + +interface VersionActionDialogProps { + action: 'add' | 'delete' + open: boolean + setOpen: (open: boolean) => void +} + +export function VersionActionDialog({ + open, + setOpen, + action, +}: VersionActionDialogProps) { + const { selectedSecret, selectedVersions } = useSecretsContext() + const [loading, setLoading] = useState(false) + const [value, setValue] = useState('') + const inputRef = useRef(null) + const secretName = selectedSecret?.name + + const { + addSecretVersion, + deleteSecretVersion, + mutate: refresh, + } = useSecret(secretName) + + const handleSubmit = async () => { + setLoading(true) + + if (action === 'add') { + if (!value.trim()) { + toast.error('Secret value is required') + setLoading(false) + inputRef.current?.focus() + return + } + + await addSecretVersion(value) + setValue('') + } else { + if (!selectedVersions) { + throw new Error('Selected versions are not provided') + } + + await Promise.all([ + selectedVersions.map((version) => deleteSecretVersion(version)), + ]) + } + + await new Promise((resolve) => setTimeout(resolve, 600)) + await refresh() + + setOpen(false) + setLoading(false) + } + + return ( + + + + + {action === 'add' + ? `Add new version to ${secretName}` + : `Are you sure that you want to delete the selected ${selectedVersions?.length} versions of ${secretName}?`} + + + {action === 'add' + ? `Input the new secret value.` + : `Once deleted the versions cannot be recovered.`} + + +
    +
    + {action === 'add' && ( + <> + + setValue(e.target.value)} + placeholder="Secret value" + className="col-span-3" + /> + + )} +
    +
    + + + + + + + +
    +
    + ) +} diff --git a/pkg/dashboard/frontend/src/components/shared/DataTable.tsx b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx new file mode 100644 index 000000000..d068de6e7 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx @@ -0,0 +1,113 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type RowSelectionState, + type SortingState, + useReactTable, +} from '@tanstack/react-table' + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useEffect, useState } from 'react' +import SectionCard from './SectionCard' + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + noResultsChildren?: React.ReactNode + headerSiblings?: (state: RowSelectionState) => React.ReactNode + title?: string +} + +export function DataTable({ + columns, + data, + noResultsChildren = 'No results.', + headerSiblings, + title, +}: DataTableProps) { + const [sorting, setSorting] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onRowSelectionChange: setRowSelection, + state: { + sorting, + rowSelection, + }, + }) + + // reset row selection when data changes + useEffect(() => { + setRowSelection({}) + }, [data]) + + return ( + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {noResultsChildren} + + + )} + +
    +
    + ) +} diff --git a/pkg/dashboard/frontend/src/components/shared/SectionCard.tsx b/pkg/dashboard/frontend/src/components/shared/SectionCard.tsx new file mode 100644 index 000000000..22de68a1b --- /dev/null +++ b/pkg/dashboard/frontend/src/components/shared/SectionCard.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/lib/utils' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '../ui/card' + +interface SectionCardProps { + title?: string + description?: string + children: React.ReactNode + className?: string + innerClassName?: string + headerClassName?: string + headerSiblings?: React.ReactNode + footer?: React.ReactNode +} + +const SectionCard = ({ + title, + description, + children, + className, + innerClassName, + headerClassName, + headerSiblings, + footer, +}: SectionCardProps) => { + return ( + + {title && ( + +
    + + {title} + + {headerSiblings} +
    + {description && {description}} +
    + )} + + {children} + + {footer && {footer}} +
    + ) +} + +export default SectionCard diff --git a/pkg/dashboard/frontend/src/components/shared/Tabs.tsx b/pkg/dashboard/frontend/src/components/shared/Tabs.tsx index 8874d692c..e838aaa00 100644 --- a/pkg/dashboard/frontend/src/components/shared/Tabs.tsx +++ b/pkg/dashboard/frontend/src/components/shared/Tabs.tsx @@ -22,7 +22,7 @@ interface Props { const Tabs: React.FC = ({ tabs, index, setIndex, round, pill }) => { return ( -
    +
    -
    -
    - -
    -
    + + +
    ) : !buckets?.length ? ( diff --git a/pkg/dashboard/frontend/src/components/ui/checkbox.tsx b/pkg/dashboard/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..57fed414e --- /dev/null +++ b/pkg/dashboard/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' + +import { cn } from '@/lib/utils/cn' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/pkg/dashboard/frontend/src/components/ui/dialog.tsx b/pkg/dashboard/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..55d6783c7 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils/cn' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/pkg/dashboard/frontend/src/components/ui/table.tsx b/pkg/dashboard/frontend/src/components/ui/table.tsx new file mode 100644 index 000000000..4cc831988 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils/cn' + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    + + +)) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = 'TableBody' + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', + className, + )} + {...props} + /> +)) +TableFooter.displayName = 'TableFooter' + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = 'TableCell' + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +TableCaption.displayName = 'TableCaption' + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/pkg/dashboard/frontend/src/components/websockets/WSExplorer.tsx b/pkg/dashboard/frontend/src/components/websockets/WSExplorer.tsx index 7a0dff84f..6807ef2b1 100644 --- a/pkg/dashboard/frontend/src/components/websockets/WSExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/websockets/WSExplorer.tsx @@ -36,19 +36,14 @@ import { format } from 'date-fns/format' import { Input } from '../ui/input' import { ScrollArea } from '../ui/scroll-area' import CodeEditor from '../apis/CodeEditor' -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from '../ui/card' + import { Textarea } from '../ui/textarea' import useSWRSubscription from 'swr/subscription' import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' import { Badge } from '../ui/badge' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import BreadCrumbs from '../layout/BreadCrumbs' +import SectionCard from '../shared/SectionCard' export const LOCAL_STORAGE_KEY = 'nitric-local-dash-api-history' @@ -418,10 +413,11 @@ const WSExplorer = () => { - - - Messages -
    + { Connections: {wsInfo?.connectionCount || 0}
    -
    - - setMonitorMessageFilter(evt.target.value) - } - /> + } + > +
    + + setMonitorMessageFilter(evt.target.value) + } + /> - -
    - - -
    - {wsInfo?.messages?.length ? ( - - {wsInfo.messages - .filter((message) => { - let pass = true - - if ( - monitorMessageFilter && - typeof message.data === 'string' - ) { - pass = message.data - .toLowerCase() - .includes( - monitorMessageFilter.toLowerCase(), - ) - } - - return pass - }) - .map((message, i) => { - const shouldBeJSON = /^[{[]/.test( - message.data.trim(), - ) - - return ( - - - -
    - -
    - - {message.data} - - - {format( - new Date(message.time), - 'HH:mm:ss', - )} - -
    - - {message.data === - 'Binary messages are not currently supported by AWS' ? ( -

    - Binary messages are not currently - supported by AWS. Util this is - supported, use a text-based payload. -

    - ) : ( - - )} -
    -
    -
    - ) - })} -
    - ) : ( - - Send a message to get a response. - - )} -
    -
    - - - - - - Query Params - - -
    - { - setQueryParams(rows) - }} - /> -
    -
    -
    - - - Message - - - {payloadType === 'text' && ( -