From 4ead9b756b98747ce029d6a83255d4797c5ee530 Mon Sep 17 00:00:00 2001 From: Case Delst Date: Wed, 11 Sep 2024 14:19:09 -0700 Subject: [PATCH 1/7] Implement environment update functionality in launchdevly ui --- internal/dev_server/adapters/api.go | 21 ++- internal/dev_server/adapters/mocks/api.go | 15 ++ internal/dev_server/api/api.yaml | 32 ++++ internal/dev_server/api/server.gen.go | 107 ++++++++++++ internal/dev_server/api/server.go | 50 ++++-- internal/dev_server/db/sqlite.go | 4 +- internal/dev_server/db/sqlite_test.go | 12 +- internal/dev_server/dev_server.go | 4 +- internal/dev_server/model/project.go | 21 ++- internal/dev_server/model/variations.go | 5 + internal/dev_server/ui/src/App.tsx | 119 ++++++++++++-- internal/dev_server/ui/src/ContextEditor.tsx | 53 ++++++ .../dev_server/ui/src/EnvironmentSelector.tsx | 127 +++++++++++++++ internal/dev_server/ui/src/Flags.tsx | 13 +- internal/dev_server/ui/src/ProjectEditor.tsx | 152 ++++++++++++++++++ internal/dev_server/ui/src/SubmitButton.tsx | 38 +++++ internal/dev_server/ui/src/api.ts | 11 ++ internal/dev_server/ui/src/types.ts | 4 + 18 files changed, 747 insertions(+), 41 deletions(-) create mode 100644 internal/dev_server/ui/src/ContextEditor.tsx create mode 100644 internal/dev_server/ui/src/EnvironmentSelector.tsx create mode 100644 internal/dev_server/ui/src/ProjectEditor.tsx create mode 100644 internal/dev_server/ui/src/SubmitButton.tsx create mode 100644 internal/dev_server/ui/src/types.ts diff --git a/internal/dev_server/adapters/api.go b/internal/dev_server/adapters/api.go index 308ef224..edf10605 100644 --- a/internal/dev_server/adapters/api.go +++ b/internal/dev_server/adapters/api.go @@ -6,8 +6,9 @@ import ( "net/url" "strconv" - ldapi "github.com/launchdarkly/api-client-go/v14" "github.com/pkg/errors" + + ldapi "github.com/launchdarkly/api-client-go/v14" ) const ctxKeyApi = ctxKey("adapters.api") @@ -24,6 +25,7 @@ func GetApi(ctx context.Context) Api { type Api interface { GetSdkKey(ctx context.Context, projectKey, environmentKey string) (string, error) GetAllFlags(ctx context.Context, projectKey string) ([]ldapi.FeatureFlag, error) + GetProjectEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error) } type apiClientApi struct { @@ -52,6 +54,15 @@ func (a apiClientApi) GetAllFlags(ctx context.Context, projectKey string) ([]lda return flags, err } +func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error) { + log.Printf("Fetching all environments for project '%s'", projectKey) + environments, err := a.getEnvironments(ctx, projectKey) + if err != nil { + err = errors.Wrap(err, "unable to get environments from LD API") + } + return environments, err +} + func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *string) ([]ldapi.FeatureFlag, error) { var featureFlags *ldapi.FeatureFlags var err error @@ -87,6 +98,14 @@ func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *str return flags, nil } +func (a apiClientApi) getEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error) { + environments, _, err := a.apiClient.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey).Limit(1000).Execute() + if err != nil { + return nil, err + } + return environments.Items, nil +} + func parseHref(href string) (limit, offset int64, err error) { parsedUrl, err := url.Parse(href) if err != nil { diff --git a/internal/dev_server/adapters/mocks/api.go b/internal/dev_server/adapters/mocks/api.go index 26d75bc6..58e8f847 100644 --- a/internal/dev_server/adapters/mocks/api.go +++ b/internal/dev_server/adapters/mocks/api.go @@ -55,6 +55,21 @@ func (mr *MockApiMockRecorder) GetAllFlags(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllFlags", reflect.TypeOf((*MockApi)(nil).GetAllFlags), arg0, arg1) } +// GetProjectEnvironments mocks base method. +func (m *MockApi) GetProjectEnvironments(arg0 context.Context, arg1 string) ([]ldapi.Environment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProjectEnvironments", arg0, arg1) + ret0, _ := ret[0].([]ldapi.Environment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProjectEnvironments indicates an expected call of GetProjectEnvironments. +func (mr *MockApiMockRecorder) GetProjectEnvironments(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectEnvironments", reflect.TypeOf((*MockApi)(nil).GetProjectEnvironments), arg0, arg1) +} + // GetSdkKey mocks base method. func (m *MockApi) GetSdkKey(arg0 context.Context, arg1, arg2 string) (string, error) { m.ctrl.T.Helper() diff --git a/internal/dev_server/api/api.yaml b/internal/dev_server/api/api.yaml index bdb9c89e..2dadf333 100644 --- a/internal/dev_server/api/api.yaml +++ b/internal/dev_server/api/api.yaml @@ -129,6 +129,27 @@ paths: description: OK. override removed 404: description: no matching override found + /dev/projects/{projectKey}/environments: + get: + operationId: getProjectsEnvironments + summary: list all environments for the given project + parameters: + - $ref: "#/components/parameters/projectKey" + responses: + 200: + description: OK. List of environments + content: + application/json: + schema: + description: list of environments + type: array + items: + $ref: "#/components/schemas/Environment" + uniqueItems: true + 404: + $ref: "#/components/responses/ErrorResponse" + 400: + $ref: "#/components/responses/ErrorResponse" components: parameters: flagKey: @@ -224,6 +245,17 @@ components: type: integer x-go-type: int64 description: unix timestamp for the lat time the flag values were synced from the source environment + Environment: + description: Environment + type: object + required: + - key + - name + properties: + key: + type: string + name: + type: string responses: FlagOverride: description: Flag override diff --git a/internal/dev_server/api/server.gen.go b/internal/dev_server/api/server.gen.go index aef4e550..ef7e4ec4 100644 --- a/internal/dev_server/api/server.gen.go +++ b/internal/dev_server/api/server.gen.go @@ -44,6 +44,12 @@ const ( // Context context object to use when evaluating flags in source environment type Context = ldcontext.Context +// Environment Environment +type Environment struct { + Key string `json:"key"` + Name string `json:"name"` +} + // FlagValue value of a feature flag variation type FlagValue = ldvalue.Value @@ -185,6 +191,9 @@ type ServerInterface interface { // Add the project to the dev server // (POST /dev/projects/{projectKey}) PostDevProjectsProjectKey(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, params PostDevProjectsProjectKeyParams) + // list all environments for the given project + // (GET /dev/projects/{projectKey}/environments) + GetProjectsEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) // remove override for flag // (DELETE /dev/projects/{projectKey}/overrides/{flagKey}) DeleteDevProjectsProjectKeyOverridesFlagKey(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, flagKey FlagKey) @@ -357,6 +366,32 @@ func (siw *ServerInterfaceWrapper) PostDevProjectsProjectKey(w http.ResponseWrit handler.ServeHTTP(w, r.WithContext(ctx)) } +// GetProjectsEnvironments operation middleware +func (siw *ServerInterfaceWrapper) GetProjectsEnvironments(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "projectKey" ------------- + var projectKey ProjectKey + + err = runtime.BindStyledParameterWithOptions("simple", "projectKey", mux.Vars(r)["projectKey"], &projectKey, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectKey", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetProjectsEnvironments(w, r, projectKey) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + // DeleteDevProjectsProjectKeyOverridesFlagKey operation middleware func (siw *ServerInterfaceWrapper) DeleteDevProjectsProjectKeyOverridesFlagKey(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -587,6 +622,8 @@ func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.H r.HandleFunc(options.BaseURL+"/dev/projects/{projectKey}", wrapper.PostDevProjectsProjectKey).Methods("POST") + r.HandleFunc(options.BaseURL+"/dev/projects/{projectKey}/environments", wrapper.GetProjectsEnvironments).Methods("GET") + r.HandleFunc(options.BaseURL+"/dev/projects/{projectKey}/overrides/{flagKey}", wrapper.DeleteDevProjectsProjectKeyOverridesFlagKey).Methods("DELETE") r.HandleFunc(options.BaseURL+"/dev/projects/{projectKey}/overrides/{flagKey}", wrapper.PutDevProjectsProjectKeyOverridesFlagKey).Methods("PUT") @@ -751,6 +788,47 @@ func (response PostDevProjectsProjectKey409JSONResponse) VisitPostDevProjectsPro return json.NewEncoder(w).Encode(response) } +type GetProjectsEnvironmentsRequestObject struct { + ProjectKey ProjectKey `json:"projectKey"` +} + +type GetProjectsEnvironmentsResponseObject interface { + VisitGetProjectsEnvironmentsResponse(w http.ResponseWriter) error +} + +type GetProjectsEnvironments200JSONResponse []Environment + +func (response GetProjectsEnvironments200JSONResponse) VisitGetProjectsEnvironmentsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetProjectsEnvironments400JSONResponse struct{ ErrorResponseJSONResponse } + +func (response GetProjectsEnvironments400JSONResponse) VisitGetProjectsEnvironmentsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type GetProjectsEnvironments404JSONResponse struct { + // Code specific error code encountered + Code string `json:"code"` + + // Message description of the error + Message string `json:"message"` +} + +func (response GetProjectsEnvironments404JSONResponse) VisitGetProjectsEnvironmentsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type DeleteDevProjectsProjectKeyOverridesFlagKeyRequestObject struct { ProjectKey ProjectKey `json:"projectKey"` FlagKey FlagKey `json:"flagKey"` @@ -847,6 +925,9 @@ type StrictServerInterface interface { // Add the project to the dev server // (POST /dev/projects/{projectKey}) PostDevProjectsProjectKey(ctx context.Context, request PostDevProjectsProjectKeyRequestObject) (PostDevProjectsProjectKeyResponseObject, error) + // list all environments for the given project + // (GET /dev/projects/{projectKey}/environments) + GetProjectsEnvironments(ctx context.Context, request GetProjectsEnvironmentsRequestObject) (GetProjectsEnvironmentsResponseObject, error) // remove override for flag // (DELETE /dev/projects/{projectKey}/overrides/{flagKey}) DeleteDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, request DeleteDevProjectsProjectKeyOverridesFlagKeyRequestObject) (DeleteDevProjectsProjectKeyOverridesFlagKeyResponseObject, error) @@ -1032,6 +1113,32 @@ func (sh *strictHandler) PostDevProjectsProjectKey(w http.ResponseWriter, r *htt } } +// GetProjectsEnvironments operation middleware +func (sh *strictHandler) GetProjectsEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) { + var request GetProjectsEnvironmentsRequestObject + + request.ProjectKey = projectKey + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetProjectsEnvironments(ctx, request.(GetProjectsEnvironmentsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetProjectsEnvironments") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetProjectsEnvironmentsResponseObject); ok { + if err := validResponse.VisitGetProjectsEnvironmentsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // DeleteDevProjectsProjectKeyOverridesFlagKey operation middleware func (sh *strictHandler) DeleteDevProjectsProjectKeyOverridesFlagKey(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, flagKey FlagKey) { var request DeleteDevProjectsProjectKeyOverridesFlagKeyRequestObject diff --git a/internal/dev_server/api/server.go b/internal/dev_server/api/server.go index 23cdccad..753eae54 100644 --- a/internal/dev_server/api/server.go +++ b/internal/dev_server/api/server.go @@ -8,14 +8,16 @@ import ( ) //go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config oapi-codegen-cfg.yaml api.yaml -type Server struct { +var _ StrictServerInterface = server{} + +type server struct { } -func NewStrictServer() Server { - return Server{} +func NewStrictServer() StrictServerInterface { + return server{} } -func (s Server) GetDevProjects(ctx context.Context, request GetDevProjectsRequestObject) (GetDevProjectsResponseObject, error) { +func (s server) GetDevProjects(ctx context.Context, request GetDevProjectsRequestObject) (GetDevProjectsResponseObject, error) { store := model.StoreFromContext(ctx) projectKeys, err := store.GetDevProjectKeys(ctx) if err != nil { @@ -27,7 +29,7 @@ func (s Server) GetDevProjects(ctx context.Context, request GetDevProjectsReques return GetDevProjects200JSONResponse(projectKeys), nil } -func (s Server) DeleteDevProjectsProjectKey(ctx context.Context, request DeleteDevProjectsProjectKeyRequestObject) (DeleteDevProjectsProjectKeyResponseObject, error) { +func (s server) DeleteDevProjectsProjectKey(ctx context.Context, request DeleteDevProjectsProjectKeyRequestObject) (DeleteDevProjectsProjectKeyResponseObject, error) { store := model.StoreFromContext(ctx) deleted, err := store.DeleteDevProject(ctx, request.ProjectKey) if err != nil { @@ -42,7 +44,7 @@ func (s Server) DeleteDevProjectsProjectKey(ctx context.Context, request DeleteD return DeleteDevProjectsProjectKey204Response{}, nil } -func (s Server) GetDevProjectsProjectKey(ctx context.Context, request GetDevProjectsProjectKeyRequestObject) (GetDevProjectsProjectKeyResponseObject, error) { +func (s server) GetDevProjectsProjectKey(ctx context.Context, request GetDevProjectsProjectKeyRequestObject) (GetDevProjectsProjectKeyResponseObject, error) { store := model.StoreFromContext(ctx) project, err := store.GetDevProject(ctx, request.ProjectKey) if err != nil { @@ -95,7 +97,7 @@ func (s Server) GetDevProjectsProjectKey(ctx context.Context, request GetDevProj }, nil } -func (s Server) PostDevProjectsProjectKey(ctx context.Context, request PostDevProjectsProjectKeyRequestObject) (PostDevProjectsProjectKeyResponseObject, error) { +func (s server) PostDevProjectsProjectKey(ctx context.Context, request PostDevProjectsProjectKeyRequestObject) (PostDevProjectsProjectKeyResponseObject, error) { if request.Body.SourceEnvironmentKey == "" { return PostDevProjectsProjectKey400JSONResponse{ ErrorResponseJSONResponse{ @@ -160,7 +162,7 @@ func (s Server) PostDevProjectsProjectKey(ctx context.Context, request PostDevPr }, nil } -func (s Server) PatchDevProjectsProjectKey(ctx context.Context, request PatchDevProjectsProjectKeyRequestObject) (PatchDevProjectsProjectKeyResponseObject, error) { +func (s server) PatchDevProjectsProjectKey(ctx context.Context, request PatchDevProjectsProjectKeyRequestObject) (PatchDevProjectsProjectKeyResponseObject, error) { store := model.StoreFromContext(ctx) project, err := model.UpdateProject(ctx, request.ProjectKey, request.Body.Context, request.Body.SourceEnvironmentKey) if err != nil { @@ -213,7 +215,7 @@ func (s Server) PatchDevProjectsProjectKey(ctx context.Context, request PatchDev }, nil } -func (s Server) PatchDevProjectsProjectKeySync(ctx context.Context, request PatchDevProjectsProjectKeySyncRequestObject) (PatchDevProjectsProjectKeySyncResponseObject, error) { +func (s server) PatchDevProjectsProjectKeySync(ctx context.Context, request PatchDevProjectsProjectKeySyncRequestObject) (PatchDevProjectsProjectKeySyncResponseObject, error) { store := model.StoreFromContext(ctx) project, err := model.UpdateProject(ctx, request.ProjectKey, nil, nil) if err != nil { @@ -266,7 +268,7 @@ func (s Server) PatchDevProjectsProjectKeySync(ctx context.Context, request Patc }, nil } -func (s Server) DeleteDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, request DeleteDevProjectsProjectKeyOverridesFlagKeyRequestObject) (DeleteDevProjectsProjectKeyOverridesFlagKeyResponseObject, error) { +func (s server) DeleteDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, request DeleteDevProjectsProjectKeyOverridesFlagKeyRequestObject) (DeleteDevProjectsProjectKeyOverridesFlagKeyResponseObject, error) { store := model.StoreFromContext(ctx) err := store.DeactivateOverride(ctx, request.ProjectKey, request.FlagKey) if err != nil { @@ -278,7 +280,7 @@ func (s Server) DeleteDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, return DeleteDevProjectsProjectKeyOverridesFlagKey204Response{}, nil } -func (s Server) PutDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, request PutDevProjectsProjectKeyOverridesFlagKeyRequestObject) (PutDevProjectsProjectKeyOverridesFlagKeyResponseObject, error) { +func (s server) PutDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, request PutDevProjectsProjectKeyOverridesFlagKeyRequestObject) (PutDevProjectsProjectKeyOverridesFlagKeyResponseObject, error) { if request.Body == nil { return nil, errors.New("empty override body") } @@ -299,3 +301,29 @@ func (s Server) PutDevProjectsProjectKeyOverridesFlagKey(ctx context.Context, re Value: override.Value, }}, nil } + +func (s server) GetProjectsEnvironments(ctx context.Context, request GetProjectsEnvironmentsRequestObject) (GetProjectsEnvironmentsResponseObject, error) { + store := model.StoreFromContext(ctx) + project, err := store.GetDevProject(ctx, request.ProjectKey) + if err != nil { + return nil, err + } + if project == nil { + return GetProjectsEnvironments404JSONResponse{}, nil + } + + environments, err := project.Environments(ctx) + if err != nil { + return nil, err + } + + var envReps []Environment + for _, env := range environments { + envReps = append(envReps, Environment{ + Key: env.Key, + Name: env.Name, + }) + } + + return GetProjectsEnvironments200JSONResponse(envReps), nil +} diff --git a/internal/dev_server/db/sqlite.go b/internal/dev_server/db/sqlite.go index ee9e2df3..962f174d 100644 --- a/internal/dev_server/db/sqlite.go +++ b/internal/dev_server/db/sqlite.go @@ -83,9 +83,9 @@ func (s Sqlite) UpdateProject(ctx context.Context, project model.Project) (bool, }() result, err := tx.ExecContext(ctx, ` UPDATE projects - SET flag_state = ?, last_sync_time = ?, context=? + SET flag_state = ?, last_sync_time = ?, context=?, source_environment_key=? WHERE key = ?; - `, flagsStateJson, project.LastSyncTime, project.Context.JSONString(), project.Key) + `, flagsStateJson, project.LastSyncTime, project.Context.JSONString(), project.SourceEnvironmentKey, project.Key) if err != nil { return false, errors.Wrap(err, "unable to execute update project") } diff --git a/internal/dev_server/db/sqlite_test.go b/internal/dev_server/db/sqlite_test.go index 2352915f..bb77fdf8 100644 --- a/internal/dev_server/db/sqlite_test.go +++ b/internal/dev_server/db/sqlite_test.go @@ -6,13 +6,14 @@ import ( "testing" "time" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" "github.com/launchdarkly/ldcli/internal/dev_server/db" "github.com/launchdarkly/ldcli/internal/dev_server/model" - "github.com/samber/lo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDBFunctions(t *testing.T) { @@ -159,7 +160,7 @@ func TestDBFunctions(t *testing.T) { } }) - t.Run("UpdateProject updates flag state, sync time, context but not source environment key", func(t *testing.T) { + t.Run("UpdateProject updates flag state, sync time, context and source environment key", func(t *testing.T) { project := projects[0] project.Context = ldcontext.New(t.Name() + "blah") project.AllFlagsState = model.FlagsState{ @@ -167,7 +168,6 @@ func TestDBFunctions(t *testing.T) { "flag-2": model.FlagState{Value: ldvalue.String("cool beeans"), Version: 3}, } project.LastSyncTime = time.Now().Add(time.Hour) - oldSourceEnvKey := projects[0].SourceEnvironmentKey project.SourceEnvironmentKey = "new-env" project.AvailableVariations = []model.FlagVariation{ { @@ -204,7 +204,7 @@ func TestDBFunctions(t *testing.T) { assert.NotNil(t, newProj) assert.Equal(t, project.Key, newProj.Key) assert.Equal(t, project.AllFlagsState, newProj.AllFlagsState) - assert.Equal(t, oldSourceEnvKey, newProj.SourceEnvironmentKey) + assert.Equal(t, project.SourceEnvironmentKey, newProj.SourceEnvironmentKey) assert.Equal(t, project.Context, newProj.Context) assert.True(t, project.LastSyncTime.Equal(newProj.LastSyncTime)) diff --git a/internal/dev_server/dev_server.go b/internal/dev_server/dev_server.go index ae820f5b..efa1b7f9 100644 --- a/internal/dev_server/dev_server.go +++ b/internal/dev_server/dev_server.go @@ -10,6 +10,7 @@ import ( "github.com/adrg/xdg" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/launchdarkly/ldcli/internal/client" "github.com/launchdarkly/ldcli/internal/dev_server/adapters" "github.com/launchdarkly/ldcli/internal/dev_server/api" @@ -66,7 +67,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) { handler = handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))(handler) addr := fmt.Sprintf("0.0.0.0:%s", serverParams.Port) - log.Printf("Server running on %s", addr) + log.Printf("server running on %s", addr) log.Printf("Access the UI for toggling overrides at http://localhost:%s/ui or by running `ldcli dev-server ui`", serverParams.Port) server := http.Server{ Addr: addr, @@ -77,6 +78,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) { func getDBPath() string { dbFilePath, err := xdg.StateFile("ldcli/dev_server.db") + log.Printf("Using database at %s", dbFilePath) if err != nil { log.Fatalf("Unable to create state directory: %s", err) } diff --git a/internal/dev_server/model/project.go b/internal/dev_server/model/project.go index b594abaf..089cdb95 100644 --- a/internal/dev_server/model/project.go +++ b/internal/dev_server/model/project.go @@ -4,10 +4,11 @@ import ( "context" "time" + "github.com/pkg/errors" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" "github.com/launchdarkly/ldcli/internal/dev_server/adapters" - "github.com/pkg/errors" ) type Project struct { @@ -138,6 +139,24 @@ func (project Project) fetchAvailableVariations(ctx context.Context) ([]FlagVari return allVariations, nil } +func (project Project) Environments(ctx context.Context) ([]Environment, error) { + apiAdapter := adapters.GetApi(ctx) + environments, err := apiAdapter.GetProjectEnvironments(ctx, project.Key) + if err != nil { + return nil, err + } + + var allEnvironments []Environment + for _, environment := range environments { + allEnvironments = append(allEnvironments, Environment{ + Key: environment.Key, + Name: environment.Name, + }) + } + + return allEnvironments, nil +} + func (project Project) fetchFlagState(ctx context.Context) (FlagsState, error) { apiAdapter := adapters.GetApi(ctx) sdkKey, err := apiAdapter.GetSdkKey(ctx, project.Key, project.SourceEnvironmentKey) diff --git a/internal/dev_server/model/variations.go b/internal/dev_server/model/variations.go index 92450ea6..07744475 100644 --- a/internal/dev_server/model/variations.go +++ b/internal/dev_server/model/variations.go @@ -13,3 +13,8 @@ type FlagVariation struct { FlagKey string Variation } + +type Environment struct { + Key string + Name string +} diff --git a/internal/dev_server/ui/src/App.tsx b/internal/dev_server/ui/src/App.tsx index fb901a89..e41bda47 100644 --- a/internal/dev_server/ui/src/App.tsx +++ b/internal/dev_server/ui/src/App.tsx @@ -15,20 +15,30 @@ import { import { Icon } from '@launchpad-ui/icons'; import { FlagVariation } from './api.ts'; import { apiRoute, sortFlags } from './util.ts'; +import { ProjectEditor } from './ProjectEditor'; + +// Add this interface +interface Environment { + key: string; + name: string; +} function App() { const [selectedProject, setSelectedProject] = useState(null); + const [selectedEnvironment, setSelectedEnvironment] = + useState(null); const [sourceEnvironmentKey, setSourceEnvironmentKey] = useState< string | null >(null); const [overrides, setOverrides] = useState< - Record + Record >({}); const [availableVariations, setAvailableVariations] = useState< Record >({}); const [flags, setFlags] = useState(null); const [showBanner, setShowBanner] = useState(false); + const [context, setContext] = useState('{}'); const fetchDevFlags = useCallback(async () => { if (!selectedProject) { @@ -49,13 +59,32 @@ function App() { overrides, sourceEnvironmentKey, availableVariations, + context: fetchedContext, } = json; setFlags(sortFlags(flags)); setOverrides(overrides); setSourceEnvironmentKey(sourceEnvironmentKey); setAvailableVariations(availableVariations); - }, [selectedProject, setFlags, setSourceEnvironmentKey]); + setContext(JSON.stringify(fetchedContext || `{}`, null, 2)); + + // Fetch the environment details and set the selectedEnvironment + const environments = await fetchEnvironments(selectedProject); + const currentEnvironment = environments.find( + (env: Environment) => env.key === sourceEnvironmentKey, + ); + if (currentEnvironment) { + setSelectedEnvironment(currentEnvironment); + } + }, [selectedProject]); + + useEffect(() => { + if (selectedProject) { + fetchDevFlags().catch( + console.error.bind(console, 'error when fetching flags'), + ); + } + }, [fetchDevFlags, selectedProject]); // Fetch flags / overrides on mount useEffect(() => { @@ -63,6 +92,52 @@ function App() { console.error.bind(console, 'error when fetching flags'), ); }, [fetchDevFlags]); + + const updateProjectSettings = useCallback( + async (newEnvironment: Environment | null, newContext: string) => { + if (!selectedProject) { + return; + } + try { + const res = await fetch(apiRoute(`/dev/projects/${selectedProject}`), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sourceEnvironmentKey: newEnvironment?.key, + context: JSON.parse(newContext), + }), + }); + + if (!res.ok) { + throw new Error( + `Got ${res.status}, ${res.statusText} from project settings update`, + ); + } + + const json = await res.json(); + const { + flagsState: flags, + sourceEnvironmentKey, + context: fetchedContext, + } = json; + + setFlags(sortFlags(flags)); + setSourceEnvironmentKey(sourceEnvironmentKey); + setContext(JSON.stringify(fetchedContext || {}, null, 2)); + setSelectedEnvironment(newEnvironment); + + // Fetch updated flags and variations + await fetchDevFlags(); + } catch (error) { + console.error('Error updating project settings:', error); + // You might want to show an error message to the user here + } + }, + [selectedProject, fetchDevFlags], + ); + return (
- - - - - {sourceEnvironmentKey} - - - - Source Environment Key - + {selectedProject && ( + + )} , + ) => { + setOverrides(newOverrides); + }} /> )} @@ -145,4 +227,15 @@ function App() { ); } +// Add this function at the end of the file +async function fetchEnvironments(projectKey: string) { + const res = await fetch(apiRoute(`/dev/projects/${projectKey}/environments`)); + if (!res.ok) { + throw new Error( + `Got ${res.status}, ${res.statusText} from environments fetch`, + ); + } + return res.json(); +} + export default App; diff --git a/internal/dev_server/ui/src/ContextEditor.tsx b/internal/dev_server/ui/src/ContextEditor.tsx new file mode 100644 index 00000000..16bd0292 --- /dev/null +++ b/internal/dev_server/ui/src/ContextEditor.tsx @@ -0,0 +1,53 @@ +import { + Label, + TextField, + TextArea, + Text, + FieldError, +} from '@launchpad-ui/components'; +import { Stack } from '@launchpad-ui/core'; + +type Props = { + context: string; + setContext: (context: string) => void; +}; + +export function ContextEditor({ context, setContext }: Props) { + return ( + + + { + try { + JSON.parse(value); + return null; + } catch (err) { + if (err instanceof Error) { + return `Unable to parse value as JSON: ${err.toString()}`; + } else { + return `Unable to parse value as JSON: unknown parse error`; + } + } + }} + style={{ + flexGrow: 1, + display: 'flex', + flexDirection: 'column', + }} + > +