From ac3d2c8f2894daf223b59411bd1e4f24540b2dc0 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 5 Aug 2024 09:25:09 +1000 Subject: [PATCH 01/10] add secrets to architecture diagram --- pkg/dashboard/dashboard.go | 32 ++++++++++++++++++- .../frontend/cypress/e2e/architecture.cy.ts | 1 + .../frontend/cypress/e2e/schedules.cy.ts | 2 +- .../frontend/cypress/e2e/topics.cy.ts | 2 +- .../frontend/cypress/e2e/websockets.cy.ts | 8 ++--- .../architecture/nodes/SecretNode.tsx | 25 +++++++++++++++ .../src/components/architecture/styles.css | 7 ++++ .../lib/utils/generate-architecture-data.ts | 14 ++++++++ pkg/dashboard/frontend/src/types.ts | 3 ++ .../test-app/services/my-test-secret.ts | 21 ++++++++++++ 10 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 pkg/dashboard/frontend/src/components/architecture/nodes/SecretNode.tsx create mode 100644 pkg/dashboard/frontend/test-app/services/my-test-secret.ts diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index 89eb38950..97bcbaf06 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -103,6 +103,10 @@ type SQLDatabaseSpec struct { ConnectionString string `json:"connectionString"` } +type SecretSpec struct { + *BaseResourceSpec +} + type NotifierSpec struct { Bucket string `json:"bucket"` Target string `json:"target"` @@ -150,6 +154,7 @@ type Dashboard struct { topics []*TopicSpec buckets []*BucketSpec stores []*KeyValueSpec + secrets []*SecretSpec sqlDatabases []*SQLDatabaseSpec websockets []WebsocketSpec subscriptions []*SubscriberSpec @@ -181,6 +186,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"` @@ -312,6 +318,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 +584,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 { @@ -720,6 +748,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, @@ -805,6 +834,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/cypress/e2e/architecture.cy.ts b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts index d4652ec4f..fa294f434 100644 --- a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts @@ -16,6 +16,7 @@ const expectedNodes = [ 'my-second-db', 'services/my-test-service.ts', 'services/my-test-db.ts', + 'services/my-test-secret.ts', ] 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/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/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/lib/utils/generate-architecture-data.ts b/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts index 17a235bff..aae2d78ba 100644 --- a/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts +++ b/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts @@ -17,6 +17,7 @@ import { GlobeAltIcon, ArrowsRightLeftIcon, QueueListIcon, + LockClosedIcon, } from '@heroicons/react/24/outline' import { MarkerType, @@ -54,6 +55,7 @@ import { QueueNode } from '@/components/architecture/nodes/QueueNode' import { SQLNode } from '@/components/architecture/nodes/SQLNode' import { SiPostgresql } from 'react-icons/si' import { unique } from 'radash' +import { SecretNode } from '@/components/architecture/nodes/SecretNode' export const nodeTypes = { api: APINode, @@ -66,6 +68,7 @@ export const nodeTypes = { sql: SQLNode, httpproxy: HttpProxyNode, queue: QueueNode, + secret: SecretNode, } const createNode = ( @@ -178,6 +181,7 @@ const actionVerbs = [ 'Write', 'Enqueue', 'Dequeue', + 'Access', ] function verbFromNitricAction(action: string) { @@ -326,6 +330,16 @@ export function generateArchitectureData(data: WebSocketResponse): { nodes.push(node) }) + data.secrets.forEach((secret) => { + const node = createNode(secret, 'secret', { + title: secret.name, + resource: secret, + icon: LockClosedIcon, + }) + + nodes.push(node) + }) + data.sqlDatabases.forEach((sql) => { const node = createNode(sql, 'sql', { title: sql.name, diff --git a/pkg/dashboard/frontend/src/types.ts b/pkg/dashboard/frontend/src/types.ts index 137def04b..eaf3586f5 100644 --- a/pkg/dashboard/frontend/src/types.ts +++ b/pkg/dashboard/frontend/src/types.ts @@ -73,6 +73,8 @@ export type Bucket = BaseResource export type Queue = BaseResource +export type Secret = BaseResource + type ResourceType = 'bucket' | 'topic' | 'websocket' | 'kv' | 'secret' | 'queue' export type Notification = { @@ -104,6 +106,7 @@ export interface WebSocketResponse { topics: Topic[] services: Service[] stores: KeyValue[] + secrets: Secret[] sqlDatabases: SQLDatabase[] httpProxies: HttpProxy[] websockets: WebSocket[] diff --git a/pkg/dashboard/frontend/test-app/services/my-test-secret.ts b/pkg/dashboard/frontend/test-app/services/my-test-secret.ts new file mode 100644 index 000000000..47ffc9311 --- /dev/null +++ b/pkg/dashboard/frontend/test-app/services/my-test-secret.ts @@ -0,0 +1,21 @@ +import { api, secret } from '@nitric/sdk' + +const mySecret = secret('my-secret').allow('access') + +const shhApi = api('my-secret-api') + +shhApi.get('/get', async (ctx) => { + const latestValue = await mySecret.latest().access() + + ctx.res.body = latestValue.asString() + + return ctx +}) + +shhApi.post('/set', async (ctx) => { + const data = ctx.req.json() + + await mySecret.put(JSON.stringify(data)) + + return ctx +}) From 0809529799e2af9b59888a2c11db3bc17476bd34 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 5 Aug 2024 10:11:44 +1000 Subject: [PATCH 02/10] remove unnecessary dashboard address --- pkg/dashboard/dashboard.go | 8 +++----- pkg/dashboard/frontend/src/types.ts | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index 97bcbaf06..74f691be1 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -202,7 +202,6 @@ type DashboardResponse struct { CurrentVersion string `json:"currentVersion"` LatestVersion string `json:"latestVersion"` Connected bool `json:"connected"` - DashboardAddress string `json:"dashboardAddress"` } type Bucket struct { @@ -759,10 +758,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 diff --git a/pkg/dashboard/frontend/src/types.ts b/pkg/dashboard/frontend/src/types.ts index eaf3586f5..37dc1ebfc 100644 --- a/pkg/dashboard/frontend/src/types.ts +++ b/pkg/dashboard/frontend/src/types.ts @@ -122,7 +122,6 @@ export interface WebSocketResponse { currentVersion: string latestVersion: string connected: boolean - dashboardAddress: string } export interface Param { From 720572c835c6ee4d5f3c17dfb72aeb1029449385 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 6 Aug 2024 14:21:18 +1000 Subject: [PATCH 03/10] secrets dashboard add, list and delete functionality --- pkg/cloud/secrets/secret.go | 198 ++++++++++++++++++ pkg/dashboard/dashboard.go | 5 + pkg/dashboard/frontend/.eslintrc.cjs | 1 + pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts | 1 + pkg/dashboard/frontend/package.json | 4 +- .../databases/DatabasesExplorer.tsx | 147 ++++++------- .../components/layout/AppLayout/AppLayout.tsx | 71 ++++--- .../SecretVersionsTable.tsx | 83 ++++++++ .../secrets/SecretVersionsTable/columns.tsx | 114 ++++++++++ .../secrets/SecretVersionsTable/index.ts | 3 + .../src/components/secrets/SecretsContext.tsx | 56 +++++ .../components/secrets/SecretsExplorer.tsx | 121 +++++++++++ .../components/secrets/SecretsTreeView.tsx | 66 ++++++ .../secrets/VersionActionDialog.tsx | 124 +++++++++++ .../src/components/shared/DataTable.tsx | 111 ++++++++++ .../src/components/shared/Section.tsx | 44 ++++ .../frontend/src/components/ui/checkbox.tsx | 28 +++ .../frontend/src/components/ui/dialog.tsx | 120 +++++++++++ .../frontend/src/components/ui/table.tsx | 117 +++++++++++ pkg/dashboard/frontend/src/lib/constants.ts | 4 + .../frontend/src/lib/hooks/use-secret.ts | 47 +++++ .../frontend/src/lib/hooks/use-sql-meta.ts | 5 +- .../frontend/src/pages/secrets.astro | 8 + pkg/dashboard/frontend/src/types.ts | 9 +- .../test-app/services/my-test-secret.ts | 4 +- pkg/dashboard/frontend/yarn.lock | 53 ++++- pkg/dashboard/handlers.go | 94 +++++++++ 27 files changed, 1513 insertions(+), 125 deletions(-) create mode 100644 pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx create mode 100644 pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/columns.tsx create mode 100644 pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/index.ts create mode 100644 pkg/dashboard/frontend/src/components/secrets/SecretsContext.tsx create mode 100644 pkg/dashboard/frontend/src/components/secrets/SecretsExplorer.tsx create mode 100644 pkg/dashboard/frontend/src/components/secrets/SecretsTreeView.tsx create mode 100644 pkg/dashboard/frontend/src/components/secrets/VersionActionDialog.tsx create mode 100644 pkg/dashboard/frontend/src/components/shared/DataTable.tsx create mode 100644 pkg/dashboard/frontend/src/components/shared/Section.tsx create mode 100644 pkg/dashboard/frontend/src/components/ui/checkbox.tsx create mode 100644 pkg/dashboard/frontend/src/components/ui/dialog.tsx create mode 100644 pkg/dashboard/frontend/src/components/ui/table.tsx create mode 100644 pkg/dashboard/frontend/src/lib/hooks/use-secret.ts create mode 100644 pkg/dashboard/frontend/src/pages/secrets.astro diff --git a/pkg/cloud/secrets/secret.go b/pkg/cloud/secrets/secret.go index 7790ac326..d3f181cb2 100644 --- a/pkg/cloud/secrets/secret.go +++ b/pkg/cloud/secrets/secret.go @@ -21,9 +21,12 @@ import ( "context" "encoding/base64" "fmt" + "io" "os" "path/filepath" + "sort" "strings" + "sync" "github.com/google/uuid" "google.golang.org/grpc/codes" @@ -35,6 +38,7 @@ import ( type DevSecretService struct { secDir string + mu sync.RWMutex } var _ secretspb.SecretManagerServer = (*DevSecretService)(nil) @@ -75,6 +79,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 +103,7 @@ func (s *DevSecretService) Put(ctx context.Context, req *secretspb.SecretPutRequ } latestWriter.Flush() + latestFile.Close() return &secretspb.SecretPutResponse{ SecretVersion: &secretspb.SecretVersion{ @@ -108,6 +114,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 +160,195 @@ 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"` +} + +// 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) + } + + // Check whether the version is the latest + if version == "latest" { + latestVersion = SecretVersion{ + Value: string(valueResp.Value), + CreatedAt: createdAt, + } + + continue + } + + // Add the secret to the response + resp = append(resp, SecretVersion{ + Version: version, + Value: string(valueResp.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 74f691be1..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" @@ -147,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 @@ -656,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) @@ -817,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{}, 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/package.json b/pkg/dashboard/frontend/package.json index d4243f5b7..6e9effc19 100644 --- a/pkg/dashboard/frontend/package.json +++ b/pkg/dashboard/frontend/package.json @@ -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,6 +41,7 @@ "@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", diff --git a/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx b/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx index aad6692d5..763396a05 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 Section from '../shared/Section' 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/layout/AppLayout/AppLayout.tsx b/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx index 83fc37f94..164c1cc99 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, @@ -131,6 +131,11 @@ const AppLayout: React.FC = ({ href: '/storage', icon: ArchiveBoxIcon, }, + { + name: 'Secrets', + href: '/secrets', + icon: LockClosedIcon, + }, { name: 'Topics', href: '/topics', @@ -333,21 +338,23 @@ const AppLayout: React.FC = ({ Reach out to the community
- {communityLinks.map((item) => ( - - - ))} + {communityLinks + .filter((item) => item.name !== 'Sponsor') + .map((item) => ( + + + ))}
@@ -410,21 +417,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..a42599e36 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx @@ -0,0 +1,83 @@ +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..78a15ea2c --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/columns.tsx @@ -0,0 +1,114 @@ +'use client' + +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 { VersionActionDialog } from '../VersionActionDialog' +import { useSecretsContext } from '../SecretsContext' + +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', + }, + { + 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..9f3c3f433 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/secrets/VersionActionDialog.tsx @@ -0,0 +1,124 @@ +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..c9c55a1cc --- /dev/null +++ b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx @@ -0,0 +1,111 @@ +'use client' + +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 Section from './Section' + +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/Section.tsx b/pkg/dashboard/frontend/src/components/shared/Section.tsx new file mode 100644 index 000000000..f9161a99b --- /dev/null +++ b/pkg/dashboard/frontend/src/components/shared/Section.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils' + +interface SectionProps { + title?: string + children: React.ReactNode + innerClassName?: string + headerClassName?: string + headerSiblings?: React.ReactNode +} + +const Section = ({ + title, + children, + innerClassName, + headerClassName, + headerSiblings, +}: SectionProps) => { + return ( +
+
+
+
+ {title && ( +
+

+ {title} +

+ {headerSiblings} +
+ )} + {children} +
+
+
+
+ ) +} + +export default Section 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/lib/constants.ts b/pkg/dashboard/frontend/src/lib/constants.ts index 96196b55c..0234ea5a1 100644 --- a/pkg/dashboard/frontend/src/lib/constants.ts +++ b/pkg/dashboard/frontend/src/lib/constants.ts @@ -2,6 +2,10 @@ import { getHost } from './utils' export const STORAGE_API = `http://${getHost()}/api/storage` +export const SQL_API = `http://${getHost()}/api/sql` + +export const SECRETS_API = `http://${getHost()}/api/secrets` + export const TABLE_QUERY = ` SELECT tbl.schemaname AS schema_name, diff --git a/pkg/dashboard/frontend/src/lib/hooks/use-secret.ts b/pkg/dashboard/frontend/src/lib/hooks/use-secret.ts new file mode 100644 index 000000000..90288e0fb --- /dev/null +++ b/pkg/dashboard/frontend/src/lib/hooks/use-secret.ts @@ -0,0 +1,47 @@ +import { useCallback } from 'react' +import useSWR from 'swr' +import { fetcher } from './fetcher' +import type { SecretVersion } from '@/types' +import { SECRETS_API } from '../constants' + +export const useSecret = (secretName?: string) => { + const { data, mutate } = useSWR( + secretName + ? `${SECRETS_API}?action=list-versions&secret=${secretName}` + : null, + fetcher(), + ) + + const addSecretVersion = useCallback( + async (value: string) => { + return fetch( + `${SECRETS_API}?action=add-secret-version&secret=${secretName}`, + { + method: 'POST', + body: JSON.stringify({ value }), + }, + ) + }, + [secretName], + ) + + const deleteSecretVersion = useCallback( + async (sv: SecretVersion) => { + return fetch( + `${SECRETS_API}?action=delete-secret&secret=${secretName}&version=${sv.version}&latest=${sv.latest}`, + { + method: 'DELETE', + }, + ) + }, + [secretName], + ) + + return { + data, + mutate, + addSecretVersion, + deleteSecretVersion, + loading: !data, + } +} diff --git a/pkg/dashboard/frontend/src/lib/hooks/use-sql-meta.ts b/pkg/dashboard/frontend/src/lib/hooks/use-sql-meta.ts index adf57a728..13da7d6f2 100644 --- a/pkg/dashboard/frontend/src/lib/hooks/use-sql-meta.ts +++ b/pkg/dashboard/frontend/src/lib/hooks/use-sql-meta.ts @@ -1,7 +1,6 @@ import useSWR from 'swr' import { fetcher } from './fetcher' -import { TABLE_QUERY } from '../constants' -import { getHost } from '../utils' +import { SQL_API, TABLE_QUERY } from '../constants' export interface SqlMetaResult { columns: { @@ -17,7 +16,7 @@ export interface SqlMetaResult { export const useSqlMeta = (connectionString?: string) => { const { data, mutate } = useSWR( - connectionString ? `http://${getHost()}/api/sql` : null, + connectionString ? SQL_API : null, fetcher({ method: 'POST', body: JSON.stringify({ query: TABLE_QUERY, connectionString }), diff --git a/pkg/dashboard/frontend/src/pages/secrets.astro b/pkg/dashboard/frontend/src/pages/secrets.astro new file mode 100644 index 000000000..7565d97ce --- /dev/null +++ b/pkg/dashboard/frontend/src/pages/secrets.astro @@ -0,0 +1,8 @@ +--- +import SecretsExplorer from "@/components/secrets/SecretsExplorer"; +import Layout from "@/layouts/Layout.astro"; +--- + + + + diff --git a/pkg/dashboard/frontend/src/types.ts b/pkg/dashboard/frontend/src/types.ts index 37dc1ebfc..203043122 100644 --- a/pkg/dashboard/frontend/src/types.ts +++ b/pkg/dashboard/frontend/src/types.ts @@ -75,6 +75,13 @@ export type Queue = BaseResource export type Secret = BaseResource +export interface SecretVersion { + version: string + value: string + createdAt: string + latest: boolean +} + type ResourceType = 'bucket' | 'topic' | 'websocket' | 'kv' | 'secret' | 'queue' export type Notification = { @@ -118,7 +125,7 @@ export interface WebSocketResponse { apiAddresses: Record websocketAddresses: Record httpWorkerAddresses: Record - storageAddress: string // has http:// prefix + storageAddress: string currentVersion: string latestVersion: string connected: boolean diff --git a/pkg/dashboard/frontend/test-app/services/my-test-secret.ts b/pkg/dashboard/frontend/test-app/services/my-test-secret.ts index 47ffc9311..ad2386996 100644 --- a/pkg/dashboard/frontend/test-app/services/my-test-secret.ts +++ b/pkg/dashboard/frontend/test-app/services/my-test-secret.ts @@ -1,6 +1,8 @@ import { api, secret } from '@nitric/sdk' -const mySecret = secret('my-secret').allow('access') +const mySecret = secret('my-secret').allow('access', 'put') + +const mySecondSecret = secret('my-second-secret').allow('access', 'put') const shhApi = api('my-secret-api') diff --git a/pkg/dashboard/frontend/yarn.lock b/pkg/dashboard/frontend/yarn.lock index cb4c8c608..d058b7239 100644 --- a/pkg/dashboard/frontend/yarn.lock +++ b/pkg/dashboard/frontend/yarn.lock @@ -1732,6 +1732,20 @@ dependencies: "@radix-ui/react-primitive" "2.0.0" +"@radix-ui/react-checkbox@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz#a559c4303957d797acee99914480b755aa1f27d6" + integrity sha512-0i/EKJ222Afa1FE0C6pNJxDq1itzcl3HChE9DwskA4th4KRse8ojx8a1nVcOjwJdbpDLcz7uol77yYnQNMHdKw== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-presence" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/react-collapsible@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" @@ -1804,7 +1818,7 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.0.tgz#6df8d983546cfd1999c8512f3a8ad85a6e7fcee8" integrity sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A== -"@radix-ui/react-dialog@^1.0.4", "@radix-ui/react-dialog@^1.0.5": +"@radix-ui/react-dialog@^1.0.4": version "1.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300" integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q== @@ -1825,6 +1839,26 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.5" +"@radix-ui/react-dialog@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz#4906507f7b4ad31e22d7dad69d9330c87c431d44" + integrity sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-dismissable-layer" "1.1.0" + "@radix-ui/react-focus-guards" "1.1.0" + "@radix-ui/react-focus-scope" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-portal" "1.1.1" + "@radix-ui/react-presence" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.7" + "@radix-ui/react-direction@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" @@ -2334,6 +2368,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-previous@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c" + integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== + "@radix-ui/react-use-rect@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2" @@ -2591,6 +2630,18 @@ dependencies: mini-svg-data-uri "^1.2.3" +"@tanstack/react-table@^8.20.1": + version "8.20.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.20.1.tgz#bd2d549d8a18458fb8284025ce66a9b86176fa6b" + integrity sha512-PJK+07qbengObe5l7c8vCdtefXm8cyR4i078acWrHbdm8JKw1ES7YpmOtVt9ALUVEEFAHscdVpGRhRgikgFMbQ== + dependencies: + "@tanstack/table-core" "8.20.1" + +"@tanstack/table-core@8.20.1": + version "8.20.1" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.1.tgz#74bfab10fa35bed51fa0bd2f3539a331d7e78f1b" + integrity sha512-5Ly5TIRHnWH7vSDell9B/OVyV380qqIJVg7H7R7jU4fPEmOD4smqAX7VRflpYI09srWR8aj5OLD2Ccs1pI5mTg== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" diff --git a/pkg/dashboard/handlers.go b/pkg/dashboard/handlers.go index 177fc6cd8..2758e3fae 100644 --- a/pkg/dashboard/handlers.go +++ b/pkg/dashboard/handlers.go @@ -37,6 +37,7 @@ import ( "github.com/nitrictech/cli/pkg/cloud/websockets" base_http "github.com/nitrictech/nitric/cloud/common/runtime/gateway" apispb "github.com/nitrictech/nitric/core/pkg/proto/apis/v1" + secretspb "github.com/nitrictech/nitric/core/pkg/proto/secrets/v1" storagepb "github.com/nitrictech/nitric/core/pkg/proto/storage/v1" ) @@ -285,6 +286,99 @@ func (d *Dashboard) createSqlQueryHandler() func(http.ResponseWriter, *http.Requ } } +func (d *Dashboard) createSecretsHandler() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "*") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + secretName := r.URL.Query().Get("secret") + action := r.URL.Query().Get("action") + version := r.URL.Query().Get("version") + latest := r.URL.Query().Get("latest") + + if secretName == "" { + http.Error(w, "missing secret param", http.StatusBadRequest) + return + } + + switch action { + case "list-versions": + secretVersions, err := d.secretService.List(context.Background(), secretName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonResponse, err := json.Marshal(secretVersions) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + _, err = w.Write(jsonResponse) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + case "add-secret-version": + // get data from body + var requestBody struct { + Value string `json:"value"` + } + + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if requestBody.Value == "" { + http.Error(w, "missing value param", http.StatusBadRequest) + return + } + + _, err = d.secretService.Put(context.Background(), &secretspb.SecretPutRequest{ + Secret: &secretspb.Secret{ + Name: secretName, + }, + Value: []byte(requestBody.Value), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + case "delete-secret": + if version == "" { + http.Error(w, "missing version param", http.StatusBadRequest) + return + } + + err := d.secretService.Delete(context.Background(), secretName, version, latest == "true") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + } +} + func (d *Dashboard) createHistoryHttpHandler() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") From 17d880ecc6f868caa010b9488f060b76eb092323 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 6 Aug 2024 16:32:56 +1000 Subject: [PATCH 04/10] add secret tests --- .../frontend/cypress/e2e/architecture.cy.ts | 2 + .../frontend/cypress/e2e/secrets.cy.ts | 166 ++++++++++++++++++ .../SecretVersionsTable.tsx | 2 + .../secrets/SecretVersionsTable/columns.tsx | 9 +- .../secrets/VersionActionDialog.tsx | 7 +- .../src/components/shared/DataTable.tsx | 6 +- .../test-app/services/my-test-secret.ts | 2 +- 7 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 pkg/dashboard/frontend/cypress/e2e/secrets.cy.ts diff --git a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts index fa294f434..f10970c9d 100644 --- a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts @@ -17,6 +17,8 @@ const expectedNodes = [ '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/secrets.cy.ts b/pkg/dashboard/frontend/cypress/e2e/secrets.cy.ts new file mode 100644 index 000000000..d4fb1b9a5 --- /dev/null +++ b/pkg/dashboard/frontend/cypress/e2e/secrets.cy.ts @@ -0,0 +1,166 @@ +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 ${sec}`, () => { + 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') + }) +}) diff --git a/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx index a42599e36..2837cacab 100644 --- a/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx +++ b/pkg/dashboard/frontend/src/components/secrets/SecretVersionsTable/SecretVersionsTable.tsx @@ -38,6 +38,7 @@ export const SecretVersionsTable = () => { - diff --git a/pkg/dashboard/frontend/src/components/shared/DataTable.tsx b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx index c9c55a1cc..1b4a59706 100644 --- a/pkg/dashboard/frontend/src/components/shared/DataTable.tsx +++ b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx @@ -88,10 +88,14 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} diff --git a/pkg/dashboard/frontend/test-app/services/my-test-secret.ts b/pkg/dashboard/frontend/test-app/services/my-test-secret.ts index ad2386996..dcda10204 100644 --- a/pkg/dashboard/frontend/test-app/services/my-test-secret.ts +++ b/pkg/dashboard/frontend/test-app/services/my-test-secret.ts @@ -1,6 +1,6 @@ import { api, secret } from '@nitric/sdk' -const mySecret = secret('my-secret').allow('access', 'put') +const mySecret = secret('my-first-secret').allow('access', 'put') const mySecondSecret = secret('my-second-secret').allow('access', 'put') From a12da688c9f4f21fa0be31f17d0db54002147c13 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 6 Aug 2024 17:07:47 +1000 Subject: [PATCH 05/10] refactoring and cleanup new card section component reused --- .../src/components/apis/APIExplorer.tsx | 400 ++++++------ .../src/components/apis/APIHistory.tsx | 135 ++-- .../databases/DatabasesExplorer.tsx | 10 +- .../src/components/events/EventsExplorer.tsx | 264 ++++---- .../components/layout/AppLayout/AppLayout.tsx | 17 +- .../src/components/shared/DataTable.tsx | 8 +- .../src/components/shared/Section.tsx | 44 -- .../src/components/shared/SectionCard.tsx | 53 ++ .../frontend/src/components/shared/Tabs.tsx | 4 +- .../src/components/storage/FileBrowser.tsx | 66 +- .../components/storage/StorageExplorer.tsx | 9 +- .../src/components/websockets/WSExplorer.tsx | 611 +++++++++--------- 12 files changed, 794 insertions(+), 827 deletions(-) delete mode 100644 pkg/dashboard/frontend/src/components/shared/Section.tsx create mode 100644 pkg/dashboard/frontend/src/components/shared/SectionCard.tsx 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/databases/DatabasesExplorer.tsx b/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx index 763396a05..2cf8fa6fa 100644 --- a/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/databases/DatabasesExplorer.tsx @@ -23,7 +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 Section from '../shared/Section' +import SectionCard from '../shared/SectionCard' interface QueryHistoryItem { query: string @@ -239,7 +239,7 @@ const DatabasesExplorer: React.FC = () => {
    -
    +
    {
    -
    -
    + +
    {
    - +
    ) : !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 164c1cc99..b64cdc641 100644 --- a/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx +++ b/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx @@ -116,11 +116,6 @@ const AppLayout: React.FC = ({ href: '/databases', icon: CircleStackIcon, }, - { - name: 'Websockets', - href: '/websockets', - icon: ChatBubbleLeftRightIcon, - }, { name: 'Schedules', href: '/schedules', @@ -131,18 +126,22 @@ const AppLayout: React.FC = ({ href: '/storage', icon: ArchiveBoxIcon, }, + { + name: 'Topics', + href: '/topics', + icon: MegaphoneIcon, + }, { name: 'Secrets', href: '/secrets', icon: LockClosedIcon, }, { - name: 'Topics', - href: '/topics', - icon: MegaphoneIcon, + 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' diff --git a/pkg/dashboard/frontend/src/components/shared/DataTable.tsx b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx index 1b4a59706..76e08f854 100644 --- a/pkg/dashboard/frontend/src/components/shared/DataTable.tsx +++ b/pkg/dashboard/frontend/src/components/shared/DataTable.tsx @@ -19,7 +19,7 @@ import { TableRow, } from '@/components/ui/table' import { useEffect, useState } from 'react' -import Section from './Section' +import SectionCard from './SectionCard' interface DataTableProps { columns: ColumnDef[] @@ -58,11 +58,11 @@ export function DataTable({ }, [data]) return ( -
    @@ -110,6 +110,6 @@ export function DataTable({ )}
    -
    + ) } diff --git a/pkg/dashboard/frontend/src/components/shared/Section.tsx b/pkg/dashboard/frontend/src/components/shared/Section.tsx deleted file mode 100644 index f9161a99b..000000000 --- a/pkg/dashboard/frontend/src/components/shared/Section.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { cn } from '@/lib/utils' - -interface SectionProps { - title?: string - children: React.ReactNode - innerClassName?: string - headerClassName?: string - headerSiblings?: React.ReactNode -} - -const Section = ({ - title, - children, - innerClassName, - headerClassName, - headerSiblings, -}: SectionProps) => { - return ( -
    -
    -
    -
    - {title && ( -
    -

    - {title} -

    - {headerSiblings} -
    - )} - {children} -
    -
    -
    -
    - ) -} - -export default Section 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/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' && ( -