瀏覽代碼

endpoint for listing app revisions

Ian Edwards 2 年之前
父節點
當前提交
53c70f0d09

+ 127 - 0
api/server/handlers/porter_app/list_app_revisions.go

@@ -0,0 +1,127 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListAppRevisionsHandler handles requests to the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListAppRevisionsHandler returns a new ListAppRevisionsHandler
+func NewListAppRevisionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListAppRevisionsHandler {
+	return &ListAppRevisionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ListAppRevisionsResponse represents the response from the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsRequest struct {
+	// The deployment target ID for the revisions
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// RevisionData represents the data for a single revision
+type RevisionData struct {
+	// B64AppProto is the base64 encoded app proto definition
+	B64AppProto string `json:"b64_app_proto"`
+	// Status is the status of the revision
+	Status string `json:"status"`
+	// RevisionNumber is the revision number with respect to the app and deployment target
+	RevisionNumber int `json:"revision_number"`
+	// UpdatedAt is the time the revision was updated
+	UpdatedAt string `json:"updated_at"`
+}
+
+// ListAppRevisionsResponse represents the response from the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsResponse struct {
+	Revisions []RevisionData `json:"revisions"`
+}
+
+func (c *ListAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-app-revisions")
+	defer span.End()
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	// get the app
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if app.ID == 0 {
+		err = telemetry.Error(ctx, span, nil, "app with name does not exist in project")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	// get the request object
+	request := &ListAppRevisionsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	// get the deployment target ID
+	deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "invalid deployment target ID")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID.String()})
+
+	// get the app revisions
+	revisions, err := c.Repo().AppRevision().AppRevisionsByAppAndDeploymentTarget(app.ID, deploymentTargetID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error querying for app revisions")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// create the response object
+	res := &ListAppRevisionsResponse{
+		Revisions: make([]RevisionData, 0),
+	}
+
+	// iterate through the revisions and add them to the response
+	for _, revision := range revisions {
+		b64 := base64.StdEncoding.EncodeToString([]byte(revision.Base64App))
+
+		res.Revisions = append(res.Revisions, RevisionData{
+			B64AppProto:    b64,
+			Status:         revision.Status,
+			RevisionNumber: revision.RevisionNumber,
+			UpdatedAt:      revision.UpdatedAt.UTC().String(),
+		})
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -745,5 +745,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions -> porter_app.NewCurrentAppRevisionHandler
+	listAppRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/revisions", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listAppRevisionsHandler := porter_app.NewListAppRevisionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listAppRevisionsEndpoint,
+		Handler:  listAppRevisionsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 30 - 1
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -15,10 +15,12 @@ import web from "assets/web.png";
 import box from "assets/box.png";
 import github from "assets/github-white.png";
 import pr_icon from "assets/pull_request_icon.svg";
+import notFound from "assets/not-found.png";
 
 import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import Link from "components/porter/Link";
 
 export const porterAppValidator = z.object({
   name: z.string(),
@@ -191,7 +193,18 @@ const AppView: React.FC<Props> = ({ match }) => {
   }
 
   if (status === "error" || porterAppStatus === "error" || !revision) {
-    return <div>error</div>;
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No application matching "{params.appName}" was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/apps">Return to dashboard</Link>
+      </Placeholder>
+    );
   }
 
   return (
@@ -221,6 +234,8 @@ const AppView: React.FC<Props> = ({ match }) => {
           </>
         )}
       </Container>
+      <Spacer y={0.5} />
+      
     </StyledExpandedApp>
   );
 };
@@ -241,6 +256,20 @@ const StyledExpandedApp = styled.div`
     }
   }
 `;
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;
 const A = styled.a`
   display: flex;
   align-items: center;

+ 12 - 0
internal/repository/app_revision.go

@@ -0,0 +1,12 @@
+package repository
+
+import (
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AppRevisionRepository represents the set of queries on the AppRevision model
+type AppRevisionRepository interface {
+	// AppRevisionsByAppAndDeploymentTarget finds all app revisions for a given app and deployment target
+	AppRevisionsByAppAndDeploymentTarget(appID uint, deploymentTargetID uuid.UUID) ([]*models.AppRevision, error)
+}

+ 30 - 0
internal/repository/gorm/app_revision.go

@@ -0,0 +1,30 @@
+package gorm
+
+import (
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AppRevisionRepository uses gorm.DB for querying the database
+type AppRevisionRepository struct {
+	db *gorm.DB
+}
+
+// NewAppRevisionRepository returns a AppRevisionRepository which uses
+// gorm.DB for querying the database
+func NewAppRevisionRepository(db *gorm.DB) repository.AppRevisionRepository {
+	return &AppRevisionRepository{db}
+}
+
+// AppRevisionsByAppAndDeploymentTarget finds all app revisions for a given app and deployment target
+func (repo *AppRevisionRepository) AppRevisionsByAppAndDeploymentTarget(appID uint, deploymentTargetID uuid.UUID) ([]*models.AppRevision, error) {
+	appRevisions := []*models.AppRevision{}
+
+	if err := repo.db.Where("porter_app_id = ? AND deployment_target_id = ?", appID, deploymentTargetID).Find(&appRevisions).Error; err != nil {
+		return nil, err
+	}
+
+	return appRevisions, nil
+}

+ 7 - 0
internal/repository/gorm/repository.go

@@ -54,6 +54,7 @@ type GormRepository struct {
 	porterApp                 repository.PorterAppRepository
 	porterAppEvent            repository.PorterAppEventRepository
 	deploymentTarget          repository.DeploymentTargetRepository
+	appRevision               repository.AppRevisionRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -245,6 +246,11 @@ func (t *GormRepository) DeploymentTarget() repository.DeploymentTargetRepositor
 	return t.deploymentTarget
 }
 
+// AppRevision returns the AppRevisionRepository interface implemented by gorm
+func (t *GormRepository) AppRevision() repository.AppRevisionRepository {
+	return t.appRevision
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -296,5 +302,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		porterApp:                 NewPorterAppRepository(db),
 		porterAppEvent:            NewPorterAppEventRepository(db),
 		deploymentTarget:          NewDeploymentTargetRepository(db),
+		appRevision:               NewAppRevisionRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -48,4 +48,5 @@ type Repository interface {
 	PorterApp() PorterAppRepository
 	PorterAppEvent() PorterAppEventRepository
 	DeploymentTarget() DeploymentTargetRepository
+	AppRevision() AppRevisionRepository
 }

+ 24 - 0
internal/repository/test/app_revision.go

@@ -0,0 +1,24 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// AppRevisionRepository is a test repository that implements repository.AppRevisionRepository
+type AppRevisionRepository struct {
+	canQuery bool
+}
+
+// NewAppRevisionRepository returns the test AppRevisionRepository
+func NewAppRevisionRepository() repository.AppRevisionRepository {
+	return &AppRevisionRepository{canQuery: false}
+}
+
+// AppRevisionsByAppAndDeploymentTarget finds all app revisions for a given app and deployment target
+func (repo *AppRevisionRepository) AppRevisionsByAppAndDeploymentTarget(appID uint, deploymentTargetID uuid.UUID) ([]*models.AppRevision, error) {
+	return nil, errors.New("cannot read database")
+}

+ 7 - 0
internal/repository/test/repository.go

@@ -52,6 +52,7 @@ type TestRepository struct {
 	porterApp                 repository.PorterAppRepository
 	porterAppEvent            repository.PorterAppEventRepository
 	deploymentTarget          repository.DeploymentTargetRepository
+	appRevision               repository.AppRevisionRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -243,6 +244,11 @@ func (t *TestRepository) DeploymentTarget() repository.DeploymentTargetRepositor
 	return t.deploymentTarget
 }
 
+// AppRevision returns a test AppRevisionRepository
+func (t *TestRepository) AppRevision() repository.AppRevisionRepository {
+	return t.appRevision
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -294,5 +300,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		porterApp:                 NewPorterAppRepository(canQuery, failingMethods...),
 		porterAppEvent:            NewPorterAppEventRepository(canQuery),
 		deploymentTarget:          NewDeploymentTargetRepository(),
+		appRevision:               NewAppRevisionRepository(),
 	}
 }