diff --git a/.gitignore b/.gitignore index 761d91d..8b09bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ target ### IntelliJ IDEA ### .idea *.iml + +### Visual Studio ### +.vscode + +### Local data files ### +/src/main/resources/data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f4b9621..0e96cc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,6 @@ -### Build stage -FROM maven:3.5.2-jdk-8 as builder -WORKDIR /tmp/build-dir -COPY . . -RUN cd /tmp/build-dir && mvn package - -### Production stage -FROM java:8-jre +FROM openjdk:8u151-jre-alpine3.7 LABEL maintainer="Oliver Hoogvliet , Raimar Falke " -RUN groupadd -r app && useradd --no-log-init -r -g app app -WORKDIR /home/app -COPY --from=builder /tmp/build-dir/container_start.sh /app/container_start.sh -RUN chmod 755 /app/container_start.sh -USER app -ENTRYPOINT ["/app/container_start.sh"] -EXPOSE 8080 -COPY --from=builder /tmp/build-dir/target/app.jar /app/app.jar +ARG APP_FILE_PATH +EXPOSE 80 +COPY $APP_FILE_PATH app.jar +CMD ["java", "-jar", "-Dserver.port=80", "app.jar"] diff --git a/README.md b/README.md index cac017d..3e7c650 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,87 @@ # AWS Codepipelines Dashboard -This is a spring boot app which serves a dashboard to see, the status -of your AWS Codepipelines. +This is a Spring Boot app which serves a dashboard to see the status of your AWS Codepipelines. -It uses the AWS Java client to fetch data from AWS. Please follow the -policy instructions below to provide access. This means that the computer -running the spring boot app must have network access to AWS. +It uses the AWS Java client to fetch data from AWS. Please follow the policy instructions below to provide access for development or set up a Role for Elastic Beanstlak deployment. For development, this means that the computer running the spring boot app must have network access to AWS. -You can also run the application on your local computer. For example -by running `mvn spring-boot:run` from the command line. There is also -a Dockerfile included to run the application in the cloud. -## Getting started with Docker -With the following command, you can run this application in a docker container: +## Getting Started Locally +### With Java/Maven +You can run the application on your local computer by running `mvn spring-boot:run` from the command line after grabbing the source (assuming Maven is installed already). There is also a Dockerfile included to run the application in a container (local or remote). +After that, you can reach the application in a web browser at +```http://localhost:8080/``` +The terminal will stream the log of your application. + +### With Docker +After you have it running with Java/Maven (which builds it), assuming you have Docker installed and running, follow the guidelines in the docker_buildspec.yml to build a Docker image. To run the app in a Docker container: ``` docker run -p8080:8080 -v`echo $HOME/.aws`:/home/app/.aws:ro --name dashboard codecentric/aws-codepipelines-dashboard ``` -After start, you can reach the application via -```http://localhost:8080/``` - -This configuration assumes that you've already an AWS account with a running -AWS CLI on your development host. - -## Instructions for setting up AWS - -You have to give/ensure the user mentioned in $HOME/.aws/credentials -a policy. The steps are: - -1) choose IAM -1) use "Policies" in navigation -1) search for "AWSCodePipelineFullAccess" -1) select Attach entities, select "Attach" -1) get your user -1) click "Attach policy" - -Check policies with this CLI command: - +After start, you can reach the application from the same URL as above. This configuration assumes that you've already an AWS account with a running AWS CLI on your development host. If you're having trouble with that, see "_Instructions for Setting up AWS permission for Development_" below. +### Instructions for setting up AWS Permission for Development +You have to give/ensure the user mentioned in $HOME/.aws/credentials has the right policy. Check policies with this CLI command: ``` aws iam list-attached-user-policies --user-name ``` - Verify that the following entry is listed: ``` { - "PolicyName": "AWSCodePipelineFullAccess", - "PolicyArn": "arn:aws:iam::aws:policy/AWSCodePipelineFullAccess" + "PolicyName": "AWSCodePipelineReadOnlyAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AWSCodePipelineReadOnlyAccess" } ``` +_AWSCodePipelineFullAccess_ will also work. If you do not have either of these, you need to attach the policy: +1. Log in to AWS as an Administrator or someone with IAM access +2. Choose IAM +3. Click "Policies" in Left Navigation +4. Search for "AWSCodePipelineReadOnlyAccess" +5. Select Attach entities, select "Attach" +6. Choose your user +7. click "Attach policy" + +### Usage +#### Display All Pipelines +Navigate to ```http://localhost:8080/``` + +#### Display Some Pipelines +Navigate to ```http://localhost:8080/#/filtered/regexp``` to display all pipelines whose name matches the regexp. + +##### Examples +Navigate to ```http://localhost:8080/#/filtered/project-[ab]``` to display all pipelines whose name contains `project-a` or `project-b` +Navigate to ```http://localhost:8080/#/filtered/(project-alpha)|(project-beta)``` to display all pipelines whose name contains `project-alpha` or `project-beta` + + +## Instructions for manually setting up a CI/CD Pipeline and Deployment on Elastic Beanstalk + +### CloudFormation Automated Deployment Instructions +Simply deploy the `deployment-cft.yml` CloudFormation template via the AWS CloudFormation Console or CLI. +* The only thing you must provide is either a GitHub Access Token or the Secret String portion of a SecretsManager Secret that has a GitHub access token +* Many other existing resources can be resued as well, if provided + * The CloudFormation Template will automatically deploy any resources that you have not provided and set up ElasticBeanstalk in the Default VPC with a CodePipeline deploying to it +* You can also configure whether approval is required to deploy updates +* You can also specify which CodeBuild Image, ElasticBeanstalk SolutionStack, and Instance Type to use +* If you specify a Custom CName Prefix, the full dashboard URL will also be listed as a Stack output. + +### Notes +* The Dashboard will be available over HTTP on port 80 (no HTTPS, not 8080 like local development) +* The Dashboard can only report on CodePipelines in the region that the Elastic Beanstalk environment is deployed to +* If this data is sensitive, you might want to restrict access in the Security Group that gets created by Elastic Beanstalk (EB will always create a Security Group, you must modify the one that it creates after creation if doing this manually) + +#### Old Instructions for manual setup - DEPRECATED - USE THE ABOVE METHOD - RETAINED FOR CICD BACKWARDS COMPATIBILITY +##### Old Deployment Notes +* Choose *Generic*->*Docker* for the Elastic Beanstalk Platform +* You will need to either create an EC2 role that hass the _AWSCodePipelineReadOnlyAccess_ managed policy attached to it, or attach that policy to the EC2 Role generated by Elatsic Beanstalk +* You will also need GitHub connectivity as well as CodeBuild, CodePipeline, and ElasticBeanstalk Service roles for this (the last 3 can be generated by AWS) +* The details of the EB environment are up to you to decide, but a basic single t1.micro seems to work fine for occasional needs +#### Old, Manual, Tedious Setup +1. (Optional) Set up EC2 Role with the managed Policy _AWSCodePipelineReadOnlyAccess_ attached to it +2. Create the Elastic Beanstalk Environment with the EC2 role as the _IAM Instance Profile_ in the *Security* settings if you have created it (if you autogenerate, attach the Managed Policy to the generated role) +3. Create a CodeBuild with *_buildspec.yml_* to build the Java artifacts (use the Amazon managed Ubuntu Java Runtime, you shouldn't need a VPC or artifacts, use an existing CodeBuild Service Role or generate a new one) +4. Create a CodeBuild with *_eb_docker_buildspec.yml_* to containerize the Java artifacts (use the Amazon managed Ubuntu Docker Runtime, ensure you specify the eb_docker_buildspec.yml, you shouldn't need a VPC or artifacts, use an existing CodeBuild Service Role or generate a new one) +5. Create a CodePipeline with: + 1. GitHub repo as the Source stage (you can use any version of the repo and any branch you see fit, as long as the necesssary files exist for CodeBuild to function) + 2. The Java CodeBuild as the first part of the Build stage, with output artifacts tagged something like "_JavaArtifacts_" + 3. The Containerize CodeBuild as the second part of the Build stage, with the input artifacts as the output artifats of the Java build (in this example, _JavaArtifacts_) and the output artifacts tagged as something like "_EBApp_" + 4. (Optional) Set up a Human approval step before deployment if uptime is critical + 5. Set up a Deploy stage to your Elastic Beanstlak environment from Step 3 with the Input artifacts as the output from the Contaizer step (in this example, _EBApp_) +6. Release the Change to trigger a new build and deployment diff --git a/buildspec.yml b/buildspec.yml new file mode 100644 index 0000000..769f3b1 --- /dev/null +++ b/buildspec.yml @@ -0,0 +1,41 @@ +version: 0.2 + +#This is the buildspec for building the application source using maven. +#The result is +# - a jar-file (e.g. example-0.0.2-SNAPSHOT.jar) +# - a app_info.txt file with informaion about the above file, and version +# (this is used whenin the CodeBuild project that builds and pushes the docker image) +# - the Dockerfile and docker_buildspec for the CodeBuild project that builds and pushes the docker image. +# (simply copied from the source files) + +phases: + build: + commands: + - echo Build started on `date` + - mvn package + post_build: + commands: + - echo Build completed on `date` + + # The following commands read the artifactId, version and packaging tags from the maven POM.xml + # and passes the info to the next stage in app_info.txt +# - APP_ARTIFACT_ID=$(grep '^archivesBaseName' build.gradle | cut -d'=' -f2|sed "s/[' ]//g") +# - APP_VERSION=$(grep '^version' build.gradle | cut -d'=' -f2|sed "s/[' ]//g") + - APP_ARTIFACT_ID=$(mvn -q -Dexec.executable="echo" -Dexec.args='${project.build.finalName}' --non-recursive exec:exec) + - APP_VERSION=$(mvn -q -Dexec.executable="echo" -Dexec.args='${project.version}' --non-recursive exec:exec) + - printf "/target/%s.jar;%s" "$APP_ARTIFACT_ID" "$APP_VERSION" > app_info.txt + +artifacts: + files: + - 'target/*.jar' + - 'container_start.sh' + - 'app_info.txt' + - 'Dockerfile' + - 'docker_buildspec.yml' + - 'eb_docker_buildspec.yml' +cache: + paths: + #Maven + - '/root/.m2/**/*' + #Gradle + #- '/root/.gradle/caches/**/*' diff --git a/container_start.sh b/container_start.sh old mode 100644 new mode 100755 diff --git a/deployment-cft.yml b/deployment-cft.yml new file mode 100644 index 0000000..6b27171 --- /dev/null +++ b/deployment-cft.yml @@ -0,0 +1,470 @@ +# This CloudFormation Template should do an end-to-end one-step deployment of the CodePipeline dashboard from source. +AWSTemplateFormatVersion: "2010-09-09" +Description: > + CodePipeline Dashboard Elastic Beanstalk Deployment and CICD Pipeline. It will deploy anything that you do not + supply. If a custom CNAME prefix is specified, the dashboard URL will be provided as an output of the template. + Elastic Beanstalk will always create the default Security Group, which allows open access on Port 80, that must be + manually locked down after stack creation if you want to limit access to the dashboard. + +Parameters: + GitHubToken: + Type: String + NoEcho: true + Description: > + GitHub OAuth/Personal Access Token for CodePipeline to use to pull source (even though repo is public). Only + needs repo read scope. Alternatively, the you can use an existing secret below. + SecretResolveString: + Type: String + Description: > + CloudFormation SecretsManage Resolve String formatted location of GitHub Access Token in the format + ':SecretString:::' for any values that are applicable. + "SecretString" is a required string (for example "MyGHSecretId:SecretString:MyJsonKey"): + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.html#dynamic-references-secretsmanager + ApprovalRequired: + Type: String + AllowedValues: + - 'Yes' + - 'No' + Default: 'No' + Description: Require Human Approval for Deployments from this pipeline? + Branch: + Type: String + Default: master + Description: Override the branch of the CodePipelnie repo to pull from GitHub for the CICD pipeline + S3Bucket: + Type: String + Description: The name of a pre-existing S3 bucket to use for temporary storage between stages of the CodePipeline + CodeBuildRoleArn: + Type: String + Description: The ARN of pre-existing the role for CodeBuild to use, should be able to write to S3 build bucket + CodePipelineRoleArn: + Type: String + Description: The Arn of pre-existing role for CodePipeline to use, should be able to read from S3 build bucket + EBServiceRoleArn: + Type: String + Description: The Arn of the pre-existing Elastic Beanstalk Service Role for Elastic Beanstalk to perform actions + InstanceProfileArn: + Type: String + Description: The Arn of the pre-existing Instance Profile for the Elastic Beanstalk EC2 instances to use + CodeBuildImage: + Type: String + Default: 'aws/codebuild/amazonlinux2-x86_64-standard:5.0' + Description: The CodeBuild Image to use to build the application + EBSolutionStack: + Type: String + Default: '64bit Amazon Linux 2023 v4.0.1 running Corretto 17' + Description: The ElasticBeanstalk Solution Stack to use, should be using the Corretto8 Platform + InstanceType: + Type: String + Default: t3.micro + Description: The instance type for the single deployed instance, must be supported in this region + CNamePrefix: + Type: String + MaxLength: 63 + Description: > + Desired unique CNAME prefix (4-63 chars) for the EB Environment, otherwise automatically generated. Note that + specifying this will prevent CloudFormation upgrades that require replacement + ConstraintDescription: Must be less than 63 charcaters + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: "GitHub Access - Provide only one of the following (Required)" + Parameters: + - GitHubToken + - SecretResolveString + - Label: + default: "Pre-Existing Components (Optional)" + Parameters: + - S3Bucket + - CodeBuildRoleArn + - CodePipelineRoleArn + - EBServiceRoleArn + - InstanceProfileArn + - Label: + default: "Overrides (Optional)" + Parameters: + - ApprovalRequired + - Branch + - CodeBuildImage + - EBSolutionStack + - InstanceType + - CNamePrefix + ParameterLabels: + GitHubToken: + default: GitHub Access Token + SecretResolveString: + default: GitHub Secret CloudFormation Resolve String + S3Bucket: + default: S3 Bucket + CodeBuildRoleArn: + default: CodeBuild Role + CodePipelineRoleArn: + default: CodePipeline Role + EBServiceRoleArn: + default: Elastic Beanstalk Service Role + InstanceProfileArn: + default: EC2 Instance Profile + ApprovalRequired: + default: Manual Approval Required + Branch: + default: Repo Branch + CodeBuildImage: + default: CodeBuild Image + EBSolutionStack: + default: Elastic Beanstalk Solution Stack + InstanceType: + default: Instance Type + CNamePrefix: + default: CNAME Prefix + +Conditions: + AutoDeploy: + !Equals [!Ref ApprovalRequired, "No"] + CreateGHSecret: + !Not [ !Equals [!Ref GitHubToken, ""]] + CreateS3: + !Equals [!Ref S3Bucket, ""] + CreateCodeBuildRole: + !Equals [!Ref CodeBuildRoleArn, ""] + CreateCodePipelineRole: + !Equals [!Ref CodePipelineRoleArn, ""] + CreateEBServiceRole: + !Equals [!Ref EBServiceRoleArn, ""] + CreateInstanceProfileAndRole: + !Equals [!Ref InstanceProfileArn, ""] + CNamePrefixSpecified: + !Not [!Equals [!Ref CNamePrefix, ""]] + +Rules: #Unfortunately, Assertions may not be enforced until a change set is executed. Still better than nothing. + GitHubTokenOrSecretExclusivelyProvided: + Assertions: #Ensure Exclusive Or between GH Token and GH Secret + - Assert: !Or [ !Not [!Equals [!Ref GitHubToken, ""]], !Not [!Equals [SecretResolveString, ""]]] + AssertDescription: Either GitHub Access Token or GitHub Secret CloudFormation Resolve String must be provided + - Assert: !Not [!And [ !Not [!Equals [!Ref GitHubToken, ""]], !Not [!Equals [!Ref SecretResolveString, ""]]]] + AssertDescription: Cannot provide both GitHub Access Token and GitHub Secret CloudFormation Resolve String + +Resources: + GHAuthTokenSecret: + Condition: CreateGHSecret + Type: AWS::SecretsManager::Secret + Properties: + Description: GH Access Token for AWS CodePipeline Dashboard Source access + SecretString: !Ref GitHubToken + + PipelineBucket: + Condition: CreateS3 + Type: AWS::S3::Bucket + Properties: + AccessControl: BucketOwnerFullControl + + CodeBuildServiceRole: + Condition: CreateCodeBuildRole + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "codebuild.amazonaws.com" + Action: + - "sts:AssumeRole" + Description: Role that CodeBuild assumes to build CodePipeline Dashboard application + Path: /service-role/ + Policies: + - PolicyName: !Sub 'CloduwatchLogGenerationPolicy' + PolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: CreateBuildLogsInCloudwatch + Effect: Allow + Resource: + - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*' + - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*:*' + Action: + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:PutLogEvents' + - PolicyName: !Sub 'S3BuildBucketAccessPolicy' + PolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: GetAndPutBuildArtifactsFromAndToS3 + Action: + - 's3:PutObject' + - 's3:GetObject' + - 's3:PutObjectVersion' + - "s3:GetObjectVersion" + Effect: Allow + Resource: !If + - CreateS3 + - !Join + - '' + - - 'arn:aws:s3:::' + - !Ref PipelineBucket + - '/*' + - !Sub 'arn:aws:s3:::${S3Bucket}/*' + + ContinuousBuild: + Type: AWS::CodeBuild::Project + Properties: + ServiceRole: !If + - CreateCodeBuildRole + - !GetAtt CodeBuildServiceRole.Arn + - !Ref CodeBuildRoleArn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: LINUX_CONTAINER + ComputeType: BUILD_GENERAL1_SMALL + Image: !Ref CodeBuildImage + EnvironmentVariables: + - Name: TARGET_BUCKET + Value: !If + - CreateS3 + - !Ref PipelineBucket + - !Ref S3Bucket + Source: + Type: CODEPIPELINE + BuildSpec: | + version: 0.2 + phases: + install: + runtime-versions: + java: corretto17 + build: + commands: + - mvn package + post_build: + commands: + - APP_JAR=$(mvn -q -Dexec.executable="echo" -Dexec.args='${project.build.finalName}' --non-recursive exec:exec) + artifacts: + files: + - 'target/$APP_JAR.jar' + discard-paths: yes + TimeoutInMinutes: 20 + + EBServiceRole: + Condition: CreateEBServiceRole + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - elasticbeanstalk.amazonaws.com + Action: + - sts:AssumeRole + Description: Role that ElasticBeanstalk Service assumes to manage resources + Path: /service-role/ + ManagedPolicyArns: [arn:aws:iam::aws:policy/AWSElasticBeanstalkManagedUpdatesCustomerRolePolicy] + + EBRole: + Condition: CreateInstanceProfileAndRole + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - sts:AssumeRole + Description: ElasticBeanstalk EC2 role that allows ReadOnly Access to CodePipeline + ManagedPolicyArns: [arn:aws:iam::aws:policy/AWSCodePipeline_ReadOnlyAccess] + + EBInstanceProfile: + Condition: CreateInstanceProfileAndRole + DependsOn: EBRole + Type: AWS::IAM::InstanceProfile + Properties: + Roles: [!Ref EBRole] + + CPDashApp: + Type: AWS::ElasticBeanstalk::Application + Properties: + Description: CodePipeline Dashboard Elastic Beanstalk App + + CPDashEnv: + Type: AWS::ElasticBeanstalk::Environment + Properties: + ApplicationName: !Ref CPDashApp + Description: CodePipeline Dashboard Elastic Beanstalk Environment + SolutionStackName: !Ref EBSolutionStack + CNAMEPrefix: !If + - CNamePrefixSpecified + - !Ref CNamePrefix + - !Ref AWS::NoValue + OptionSettings: #Defaults to Webserver Tier + - Namespace: aws:ec2:instances + OptionName: InstanceTypes + Value: !Ref InstanceType + - Namespace: aws:elasticbeanstalk:environment + OptionName: EnvironmentType + Value: SingleInstance + - Namespace: aws:elasticbeanstalk:environment + OptionName: ServiceRole + Value: !If + - CreateEBServiceRole + - !GetAtt EBServiceRole.Arn + - !Ref EBServiceRoleArn + - Namespace: aws:elasticbeanstalk:managedactions:platformupdate + OptionName: UpdateLevel + Value: minor #Managed updates on by default, take all platform updates + - Namespace: aws:autoscaling:launchconfiguration + OptionName: DisableIMDSv1 + Value: true + - Namespace: aws:autoscaling:launchconfiguration + OptionName: IamInstanceProfile + Value: !If + - CreateInstanceProfileAndRole + - !Ref EBInstanceProfile + - !Ref InstanceProfileArn + - Namespace: aws:elasticbeanstalk:application:environment + OptionName: SERVER_PORT #Have SpringBoot run on nGinx default for SampleApp compatibility using 'SERVER_PORT' + Value: 5000 #instead of having nGinx listen on SpringBoot default of 8080 using 'PORT' + + CodePipelineServiceRole: + Condition: CreateCodePipelineRole + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "codepipeline.amazonaws.com" + Action: + - "sts:AssumeRole" + Description: Role that CodePipeline assumes to manage building and deploying the CodePipeline Dashboard + Path: /service-role/ + Policies: + - PolicyName: !Sub 'CodeBuildAndElasticBeanStalkPermissions' + PolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: PutItemsCodePipelineOrElasticBeanstalkBuckets + Action: + - 's3:PutObject' + Resource: + - 'arn:aws:s3:::codepipeline*' + - 'arn:aws:s3:::elasticbeanstalk*' + Effect: Allow + - Sid: ElasticBeanstalkCreationAndDeployment + Action: + - 'elasticbeanstalk:*' + - 'ec2:*' + - 'elasticloadbalancing:*' + - 'autoscaling:*' + - 'cloudwatch:*' + - 's3:*' + - 'sns:*' + - 'cloudformation:*' + - 'rds:*' + - 'sqs:*' + - 'ecs:*' + - 'iam:PassRole' + Resource: '*' + Effect: Allow + - Sid: CodeBuildTriggeringAndStatus + Action: + - 'codebuild:BatchGetBuilds' + - 'codebuild:StartBuild' + Resource: '*' + Effect: Allow + + CiCdPipeline: + Type: AWS::CodePipeline::Pipeline + Properties: + ArtifactStore: + Type: S3 + Location: !If + - CreateS3 + - !Ref PipelineBucket + - !Ref S3Bucket + RestartExecutionOnUpdate: true + RoleArn: !If + - CreateCodePipelineRole + - !GetAtt CodePipelineServiceRole.Arn + - !Ref CodePipelineRoleArn + Stages: + - Name: Source + Actions: + - InputArtifacts: [] + Name: PullSourceFromGitHub + ActionTypeId: + Category: Source + Owner: ThirdParty + Version: '1' + Provider: GitHub #Even though CodeStar is preferred, this requires less interaction to set up + OutputArtifacts: + - Name: SourceCode + Configuration: + Owner: uplift-inc #Update here to use a different fork, didn't parameterize because not needed for our use + Repo: 'aws-codepipelines-dashboard' + Branch: !Ref Branch + OAuthToken: !If + - CreateGHSecret + - !Sub '{{resolve:secretsmanager:${GHAuthTokenSecret}}}' + - !Sub '{{resolve:secretsmanager:${SecretResolveString}}}' + PollForSourceChanges: true #Can't set up Webhook without Admin permissions, must poll + RunOrder: 1 + - Name: Build + Actions: + - InputArtifacts: + - Name: SourceCode + Name: MavenBuild + ActionTypeId: + Category: Build + Owner: AWS + Version: '1' + Provider: CodeBuild + OutputArtifacts: + - Name: AppJar + Configuration: + ProjectName: !Ref ContinuousBuild + RunOrder: 1 + - !If + - AutoDeploy + - !Ref AWS::NoValue + - Name: Human + Actions: + - InputArtifacts: [] + OutputArtifacts: [] + Name: HumanApproval + ActionTypeId: + Category: Approval + Owner: AWS + Version: 1 + Provider: Manual + - Name: Deploy + Actions: + - InputArtifacts: + - Name: AppJar + Name: DeployToElasticBeanstalk + ActionTypeId: + Category: Deploy + Owner: AWS + Version: '1' + Provider: ElasticBeanstalk + OutputArtifacts: [] + Configuration: + ApplicationName: !Ref CPDashApp + EnvironmentName: !Ref CPDashEnv + RunOrder: 1 + +Outputs: + CPDashURL: + Condition: CNamePrefixSpecified + Description: The Custom URL of the CodePipeline Dashboard for this region + Value: !Sub 'http://${CNamePrefix}.${AWS::Region}.elasticbeanstalk.com' + Export: + Name: !Sub '${AWS::Region}-CodePipelineDashboardURL' diff --git a/docker_buildspec.yml b/docker_buildspec.yml new file mode 100644 index 0000000..8cf1487 --- /dev/null +++ b/docker_buildspec.yml @@ -0,0 +1,28 @@ +version: 0.2 + +environment_variables: + plaintext: + REPOSITORY_URI: OVERRIDE_THIS_VALUE + REPOSITORY_NAME: OVERRIDE_THIS_VALUE + +phases: + pre_build: + commands: + - $(aws ecr get-login --no-include-email) + - APP_FILE_PATH="$(cat app_info.txt|cut -d';' -f1)" + - APP_VERSION="$(cat app_info.txt|cut -d';' -f2)" + - TAG=$APP_VERSION + - IMAGE_URI="${REPOSITORY_NAME}:${TAG}" + + build: + commands: + - docker build --build-arg APP_FILE_PATH=$APP_FILE_PATH --tag "$REPOSITORY_NAME" . + + post_build: + commands: + - docker tag $REPOSITORY_NAME:latest $REPOSITORY_URI:latest + - docker push "$REPOSITORY_URI" + - printf '[{"name":"%s","imageUri":"%s"}]' "$IMAGE_NAME" "$IMAGE_URI" > images.json + +artifacts: + files: images.json diff --git a/eb_docker_buildspec.yml b/eb_docker_buildspec.yml new file mode 100644 index 0000000..d9bcd10 --- /dev/null +++ b/eb_docker_buildspec.yml @@ -0,0 +1,23 @@ +version: 0.2 + +phases: + pre_build: + commands: + - APP_FILE_PATH="$(cat app_info.txt|cut -d';' -f1)" + # Move the file so that the Dockerfile can reference it directly + - cp .$APP_FILE_PATH . + - APP_FILE_PATH="${APP_FILE_PATH##*/}" + + build: + commands: + # Create the Elastic Beanstalk deployment file + - printf '{"AWSEBDockerrunVersion":"1","Ports":[{"ContainerPort":"80"}]}' > Dockerrun.aws.json + # Alter the Dockerfile to remove env variable refs, not accesssible during Beanstalk Dockerfile deployment + - mv Dockerfile Dockerfile.ORIG + - grep -v ARG Dockerfile.ORIG | sed "s/\$APP_FILE_PATH/$APP_FILE_PATH/g" > Dockerfile + +artifacts: + files: + - app.jar + - Dockerfile + - Dockerrun.aws.json diff --git a/pom.xml b/pom.xml index e195096..fa968b6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,25 +6,25 @@ de.codecentric aws-codepipelines-dashboard - 1.2-SNAPSHOT + 1.4 ${project.artifactId} Shows the status of your AWS pipelines in a dashboard org.springframework.boot spring-boot-starter-parent - 1.5.7.RELEASE + 3.1.4 UTF-8 UTF-8 - 1.8 - 1.16.16 - 1.11.172 - 3.2.1 - 2.5.2 - 2.19.1 + 17 + 1.18.30 + 1.12.566 + 3.7.1 + 2.6.14 + 2.29.4 @@ -78,7 +78,18 @@ spring-boot-starter-test test - + + junit + junit + 4.11 + test + + + org.mockito + mockito-core + 2.2.3 + test + diff --git a/src/main/java/de/codecentric/App.java b/src/main/java/de/codecentric/App.java index 5ccb256..7d9da5a 100644 --- a/src/main/java/de/codecentric/App.java +++ b/src/main/java/de/codecentric/App.java @@ -16,6 +16,6 @@ public static void main(String[] args) { @Bean public AWSCodePipeline getAwsCodePipelineClient() { - return AWSCodePipelineClientBuilder.standard().withRegion(Regions.EU_CENTRAL_1).build(); + return AWSCodePipelineClientBuilder.standard().build(); } } diff --git a/src/main/java/de/codecentric/pipeline/AwsCodePipelineFacade.java b/src/main/java/de/codecentric/pipeline/AwsCodePipelineFacade.java index ed933fb..7616e21 100644 --- a/src/main/java/de/codecentric/pipeline/AwsCodePipelineFacade.java +++ b/src/main/java/de/codecentric/pipeline/AwsCodePipelineFacade.java @@ -4,11 +4,13 @@ import com.amazonaws.services.codepipeline.model.*; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class AwsCodePipelineFacade { private final AWSCodePipeline client; - public AwsCodePipelineFacade (AWSCodePipeline awsCodePipeline) { + public AwsCodePipelineFacade(AWSCodePipeline awsCodePipeline) { this.client = awsCodePipeline; } @@ -22,20 +24,31 @@ public GetPipelineStateResult getPipelineStatus(String pipelineName) { public String getLatestCommitMessage(String pipelineName) { String latestPipelineExecutionId = getLatestPipelineExecutionId(pipelineName); - GetPipelineExecutionResult pipelineExecution = getPipelineExecutionSummary(pipelineName, latestPipelineExecutionId); - return getLatestRevisionSummary(pipelineExecution); + if (latestPipelineExecutionId != null) { + GetPipelineExecutionResult pipelineExecution = getPipelineExecutionSummary(pipelineName, latestPipelineExecutionId); + return getLatestRevisionSummary(pipelineExecution); + } + return ""; } private String getLatestPipelineExecutionId(String name) { ListPipelineExecutionsResult pipelineExecutionsResult = getLatestPipelineExecutionResult(name); - return pipelineExecutionsResult.getPipelineExecutionSummaries().get(0).getPipelineExecutionId(); + if (pipelineExecutionsResult != null) { + List summaries = pipelineExecutionsResult.getPipelineExecutionSummaries(); + if (summaries != null && summaries.size() > 0) { + return summaries.get(0).getPipelineExecutionId(); + } + } + return null; } private String getLatestRevisionSummary(GetPipelineExecutionResult pipelineExecution) { - return pipelineExecution.getPipelineExecution() - .getArtifactRevisions() - .get(0) - .getRevisionSummary(); + List revisions = pipelineExecution.getPipelineExecution() + .getArtifactRevisions(); + if (revisions.size() > 0) { + return revisions.get(0).getRevisionSummary(); + } + return ""; } private GetPipelineExecutionResult getPipelineExecutionSummary(String name, String latestPipelineExecutionId) { diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css deleted file mode 100644 index 6019853..0000000 --- a/src/main/resources/static/css/main.css +++ /dev/null @@ -1,81 +0,0 @@ -body { - font-family: Verdana, Arial, Helvetica, sans-serif; - font-size: 10pt; - background-color: black; -} - -.body_contrast { - color: white; -} - -.side { - -webkit-transform: rotate(90deg); /* Safari and Chrome */ - -moz-transform: rotate(90deg); /* Firefox */ - -ms-transform: rotate(90deg); /* IE 9 */ - -o-transform: rotate(90deg); /* Opera */ - transform: rotate(90deg); - position: absolute; - right: 0; - top: 70px; -} - -h2 { - color: #000000; - font-size: 10pt; -} - -.pipeline { - background-color: lightgray; - border: 1px solid #aaaaaa; - margin: 5px; - padding: 10px; - width: 300px; - height: 140px; - position: relative; - float: left; -} - -.stage { - font-size: 8pt; - border: 1px solid #aaaaaa; - margin: 2px; - height: 13px; -} - -.stat_succeeded { - border-color: lightgreen; - background-color: lightgreen; -} - -.stat_failed { - border-color: red; - background-color: red; -} - -.stat_inprogress { - border-color: lightblue; - background-color: lightblue; -} - -.stage_name { - width: 50%; - float: left; -} - -.stage_latestexecution { - font-size: 6pt; - text-align: right; - width: 50%; - float: right; -} - -.dateinfo { - background-color: gainsboro; - text-align: center; - font-size: 10px; - margin: 2px; - bottom: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/src/main/resources/static/js/ajaxSequencer.js b/src/main/resources/static/js/ajaxSequencer.js new file mode 100644 index 0000000..60c0298 --- /dev/null +++ b/src/main/resources/static/js/ajaxSequencer.js @@ -0,0 +1,101 @@ +/** + * @function AjaxSequencer + * + * @description Execute Ajax requests in sequence, allowing no more than `limit` to be simultaneously in-flight. + * @param {*} $ - instance of jQuery + * @param {number} limit - maximum number of simultaneous Ajax calls. Default is 3. + * + * @methods + * .get(url) - enqueues $.get(url) and returns a promise for when it is done.. + * .post(url) - enqueues $.post(url) and returns a promise for when it is done. + * .clear() - clears all queued requests, but allows in-flight requests to complete. + */ +let AjaxSequencer = function($, limit) { + + limit = limit || 3; // Set default of 3. + + let active = 0; // Number of currently active ajax requests (including queued requests). + let pending = []; // Queue of pending ajax requests. + + /** + * @function enqueue_ajax + * @description Create a deferred that, when resolved, fires the requested Ajax query, and returns a promise + * that resolves to the response to that query. Resolve it right away to start the request, if + * the number of in-flight requests is lower than `limit`. Otherwise, append it to a FIFO queue. + * Queue elements are popped off (and the corresponding request is started) when a request completes. + * @param {@} pend + * @private + */ + function enqueue_ajax(pend) { + // This is the $.Deferred that is resolved when it is time to fire the query. + pend.deferred = $.Deferred(); + + // This promise is the one that is returned to the caller. It will return the results of the $.ajax query. + let promise = pend.deferred.then(function() { + // When this $.Deferred is resolved, it fires the requested query and returns the results. + // Because this is a .then(), the results of the $.ajax() call are returned through promise.done(). + return $.ajax(pend.url, { method: pend.method }); + }).always(function() { + // A query has completed. Decrement the number of `active` requests (which is the sum of `limit` plus pending). + --active; + pend = pending.shift(); // Pull a request from the front of the array (FIFO queue). + if (pend) { + // If we have a pending request, resolve it now, which will invoke pend.deferred.then(), + // and will fire off the associated Ajax request. + pend.deferred.resolve(); + } + }); + + if (active++ < limit) { + // If we haven't reached `limit` yet, resolve this $.Deferred() immediately, so the query starts right away. + pend.deferred.resolve(); + } else { + // We've reached the `limit` so just queue this one up. It will fire when its turn comes in .always() above. + pending.push(pend); + } + + // Return the promise (with the results of the Ajax request) to the caller. If the `.then()` fails, this + // entire promise will fail. + return promise; + } + + /** + * @function get + * @description - enqueue a GET request and return a promise. + * @param {string} url + */ + function get(url) { + return enqueue_ajax({ + url: url, + method: 'GET' + }); + } + + /** + * @function post + * @description - enqueue a POST request. + * @param {string} url + */ + function post(url) { + return enqueue_ajax({ + url: url, + method: 'POST' + }); + } + + /** + * @function clear + * @description - clear the queue of pending ajax requests. + */ + function clear() { + active -= pending.length; + pending = []; + } + + // Expose clear(), get() and post(). + return { + clear: clear, + get: get, + post: post + } +} diff --git a/src/main/resources/static/js/pipelineBox.js b/src/main/resources/static/js/pipelineBox.js index 7d89994..9b25d25 100644 --- a/src/main/resources/static/js/pipelineBox.js +++ b/src/main/resources/static/js/pipelineBox.js @@ -1,94 +1,464 @@ -let pipelineService = PipelineService($); - -Vue.component('pipeline', { - props: ['pipeline'], // attribute of tag - template: ` -
{{ pipeline }} - -
Start: {{startDate}}
Duration: {{duration}} min
{{commitMessage}}
-
`, - data: function () { - return { - stages: [], - duration: 0, - startDate: "-", - commitMessage: "" - }; - }, - methods: { - getPipelineDetails: function (pipelineName) { - let componentScope = this; - pipelineService.getPipelineDetails(pipelineName, function (stages) { - componentScope.stages = stages; - let min = 0, max = 0; - let commitMessage = ""; - for (let i = 0; i < stages.length; i++) { - let lastUpdate = parseInt(stages[i].lastStatusChange); - if (min === 0) { - min = lastUpdate; - } - if (max === 0) { - max = lastUpdate; - } - if (lastUpdate > max) { - max = lastUpdate; - } - if (lastUpdate < min) { - min = lastUpdate; - } - if (commitMessage === "") { - commitMessage = stages[i].commitMessage; - } - } - componentScope.duration = ((max - min) / 60000).toFixed(1); - componentScope.startDate = moment(min).format('DD.MM.YYYY HH:mm:ss'); - componentScope.commitMessage = commitMessage; - }); - } - }, - mounted() { - this.getPipelineDetails(this.pipeline); - window.setInterval(() => this.getPipelineDetails(this.pipeline), 60000); +// Create a default AjaxSequencer and pass it to PipelineService. +let ajaxSequencer = AjaxSequencer($); +let pipelineService = PipelineService($, ajaxSequencer); + +/** + * @component - pretty much static. Take the text and insert it into the header. + */ +Vue.component("ThePageHeader", { + template: ` + <nav class="navbar navbar-light bg-light mb-4 mt-4"> + <div class="input-group-btn"> + <button type="button" class="btn btn-sm btn-secondary dropdown-toggle mr-1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> + </button> + <div class="dropdown-menu filter-dropdown-menu"> + </div> + </div> + <input class="form-control col-2 mr-2 js-filter-text" type="search"/> + <button type="button" class="btn btn-primary mr-4 js-filter-btn">Filter</button> + + <a class="navbar-brand mr-auto col-6 text-center" href="#">Dashboard</a> + + <span class="navbar-text mr-2"> + <span class="badge badge-success">succeeded</span> + </span> + + <span class="navbar-text mr-2"> + <span class="badge badge-info">in progress</span> + </span> + + <span class="navbar-text mr-2"> + <span class="badge badge-danger">failed</span> + </span> + </nav> + `, + mounted() { + // Update the title of the dashboard in the nav bar, from the text of the "title" element. + $('.navbar-brand').text($('title').text()); + // Copy previous filters from dropdown menu to js-filter-text field. + $('.filter-dropdown-menu').click(function(evt) { + if ($(evt.target).is('.js-filter-dropdown-item')) { + $('.js-filter-text').val($(evt.target).text()); + } + }); + $('.js-filter-text').on('keydown', function(evt) { + if (evt.keyCode === 13) { + $('.js-filter-btn').click(); + } + }); + $('.js-filter-btn').click(function(evt) { + var filterText = $('.js-filter-text').val(); + if (filterText) { + // Remove any dropdown-item that has exactly this text in it. :contains() will match a subset, so filter with a full comparison. + $(`.js-filter-dropdown-item:contains("${filterText}")`).filter(function() { return $(this).text() === filterText; }).remove(); + // And add a new dropdown-item at the top of the list with this text. + $('<a>').addClass('dropdown-item js-filter-dropdown-item').attr('href', '#').text(filterText).prependTo('.filter-dropdown-menu'); + router.push('/filtered/' + filterText); + } else { + router.push('/'); + } + }); + } +}); + +/** + * @component <the-pipeline-grid> - contains a list of <pipeline> components. + */ +const ThePipelineGrid = Vue.component("ThePipelineGrid", { + props: ["pipelines"], + template: ` + <div class="card-deck"> + <pipeline v-for="pipeline in pipelines" v-bind:pipeline="pipeline" :key="pipeline.name"/> + </div> + ` +}); + +/** + * @component <filtered-pipeline-grid> - contains a filtered list of <pipeline> components based on the specified URL. + */ +const FilteredPipelineGrid = Vue.component("FilteredPipelineGrid", { + props: ["filteredPipelines"], + template: ` + <div class="card-deck"> + <pipeline v-for="pipeline in filteredPipelines" v-bind:pipeline="pipeline" :key="pipeline.name"/> + </div> + ` +}); + +/** + * @component <pipeline> - contains a <pipeline-card-body> component and implements a click handler to navigate. + * + * Clicking of the body navigates to a card detail route. + */ +Vue.component("pipeline", { + props: ["pipeline"], // attribute of tag + template: ` + <pipeline-card-body v-bind:pipeline="pipeline" has-close="false" v-on:click="clickHandler"/> + `, + methods: { + clickHandler: function() { + router.push('/card/' + this.pipeline.name); + } + } +}); + +/** + * @component <pipeline-card-body> - contains a <pipeline-header> component and a list of <pipeline-stage> components. + * @property has-close="true" => display the Close button. + * emits a 'click' event when clicked on. + */ +Vue.component("PipelineCardBody", { + props: ["pipeline", "has-close"], // attribute of tag + template: ` + <div v-bind:class="['card', 'bg-light', 'mb-4']" style="min-width: 350px" v-on:click="$emit('click', $event)"> + <div class="card-body"> + <button type="button" class="close" v-bind:class="showCloseButton"> + <span class="card-close-button">×</span> + </button> + <pipeline-header v-bind:pipeline="pipeline" v-bind:states="pipeline.states"/> + </div> + <ul class="list-group list-group-flush"> + <li v-for="state in pipeline.states"> + <pipeline-state v-bind:state="state"/> + </li> + </ul> + </div> + `, + computed: { + showCloseButton: function() { + return (this.hasClose === "true") ? '' : 'd-none'; + } + } +}); + +/** + * @component <pipeline-header> - contains information about the entire Pipeline. + */ +Vue.component("PipelineHeader", { + props: ["pipeline", "states"], // attribute of tag + template: ` + <span> + <h5 class="card-title">{{ pipeline.name }}</h5> + <p class="card-text"> + <span class="text-muted mb-2"> + Started {{ startDate }} + <span class="badge badge-secondary float-right">took {{ duration }}</span> + </span><br /> + <small>{{ pipeline.commitMessage }}</small> + </p> + </span> + `, + data: function() { + return { + duration: 0, + startDate: "-" + }; + }, + watch: { + states: function(states) { + this.getPipelineDetails(this.states || []); + } + }, + mounted() { + this.getPipelineDetails(this.states || []); + }, + methods: { + getPipelineDetails: function (states) { + let min = (states.length) ? states[0].lastStatusChange : 0; + let max = Math.max.apply(Math, states.map((state) => state.lastStatusChange)); + this.duration = moment.duration(max - min).humanize(); + this.startDate = moment(min).fromNow(); } + } +}); + +/** + * @component <pipeline-state> - contains the State name, and a list of <pipeline-stage> components. + */ +Vue.component("PipelineState", { + props: ["state"], + template: ` + <div class="panel panel-default border rounded"> + <small class="panel-heading mx-3">{{ state.name }}</small> + <div class="panel-body"> + <ul class="list-group list-group-flush"> + <li v-for="stage in state.stages"> + <pipeline-stage v-bind:stage="stage"/> + </li> + </ul> + </div> + </div> + </div> + ` }); -Vue.component('stage', { - props: ['stage'], - template: ` -<div class="stage" v-bind:class="{ 'stat_succeeded': isSucceeded, 'stat_failed': isFailed, 'stat_inprogress': isInProgress }"> - <div class="stage_name"><b>{{ stage.name }}</b></div> - <div class="stage_latestexecution"><a v-bind:href="this.stage.externalExecutionUrl">{{ latestExecutionDate }}</a></div> -</div> +/** + * @component <pipeline-stage> - contains name, revision and last execution time/date. + */ +Vue.component("PipelineStage", { + props: ["stage"], + template: ` + <div class="list-group-item" v-bind:class="extraClass"> + <div class="d-flex align-items-center"> + <div class="flex-grow-1">{{ stage.name }}</div> + <div class="pl-2 pr-2 small rounded border border-secondary" v-bind:class="showRevision">{{ revisionId }}</div> + <div class="mb-1 p-1"> + <span v-bind:class="badgeType"> + <a class="text-light" v-bind:href="this.stage.externalExecutionUrl">{{ latestExecutionDate }}</a> + </span> + </div> + </div> + <div>{{ stage.errorDetails }}</div> + <div v-if="this.stage.errorDetails && this.stage.errorMessageLinks"> + <ul class="list-group list-group-flush"> + <li v-for="link in this.stage.errorMessageLinks"> + <error-message-link v-bind:link="link"/> + </li> + </ul> + </div> + </div> `, - methods: {}, - computed: { - isSucceeded: function () { - return this.stage.latestStatus === "succeeded"; - }, - isFailed: function () { - return this.stage.latestStatus === "failed"; - }, - isInProgress: function () { - return this.stage.latestStatus === "inprogress"; - }, - latestExecutionDate: function () { - return moment(this.stage.lastStatusChange).format('DD.MM.YYYY HH:mm:ss'); + methods: { + isActionRequired: function() { + for (let j=0; j < needsHumanInteraction.length; j++) { + var needs = needsHumanInteraction[j]; + if (this.matchesStage(needs, this.stage.name) && this.matchesStatus(needs, this.stage.latestStatus)) { + return true; } + } + return false; + }, + matchesStage: function(needs, name) { + return name.toLowerCase().indexOf(needs.stage.toLowerCase()) >= 0; + }, + matchesStatus: function(needs, status) { + return status.indexOf(needs.status) >= 0; } + }, + computed: { + isSucceeded: function() { + return this.stage.latestStatus === "succeeded"; + }, + isFailed: function() { + return this.stage.latestStatus === "failed"; + }, + isInProgress: function() { + return this.stage.latestStatus === "inprogress"; + }, + latestExecutionDate: function() { + if (this.stage.lastStatusChange == null) { + return ""; + } + return moment(this.stage.lastStatusChange).fromNow(); + }, + showRevision: function() { + return (this.stage.revisionId) ? '' : 'd-none'; + }, + revisionId: function() { + const revisionId = this.stage.revisionId || ""; + return revisionId.substr(0,7); + }, + badgeType: function() { + switch (this.stage.latestStatus) { + case "succeeded": + return "badge badge-success"; + case "failed": + return "badge badge-danger"; + case "inprogress": + return "badge badge-info"; + } + }, + extraClass: function() { + let extra = ''; + if (this.isActionRequired()) { + extra = 'stage-needs-action'; + } else if (this.isFailed) { + extra = 'stage-failed'; + } + return extra; + } + } +}); +/** + * @component <error-message-link> - contains a link + */ +Vue.component("ErrorMessageLink", { + props: ["link"], + template: ` + <a class="text-light" v-bind:href="this.link">{{ this.link }}</a> +` }); +/** + * @component <pipeline-card> - contains a <pipeline-header> component and ... additional information. + */ +const PipelineCard = Vue.component("PipelineCard", { + props: ["cardlines"], + template: ` + <div class="card-deck"> + <pipeline-card-body v-for="pipeline in cardlines" v-bind:pipeline="pipeline" :key="pipeline.name" has-close="true" v-on:click="navBack"/> + </div> + `, + methods: { + navBack: function(evt) { + // Only navigate back if the Close button was clicked on, not anywhere in the card. + if ($(evt.target).is('.card-close-button')) { + router.back(); + } + } + } +}); -let app = new Vue({ - el: '#app', - data: { - pipelines: [] - }, - methods: {} +// If refreshInterval is set to zero, no refreshing takes place. +// refreshInterval is set to zero with "?static" or "?refresh=0" search params. +// refreshInterval is set to 60 seconds for "?refresh" or "?refresh=" search params. +// refreshInterval is set to NN seconds for "?refresh=NN", where NN is some number of seconds. +const queryParams = window.location.search.substr(1).split('&').map((elem) => elem.split('=')).reduce((p,c) => { p[c[0]] = c[1]; return p; }, {}); +queryParams.refresh = (typeof queryParams.refresh === 'undefined') ? 60 : queryParams.refresh; +const refreshInterval = (queryParams.hasOwnProperty('static')) ? 0 : (1000 * queryParams.refresh); + +let refreshId; + +// Dummy object in case elements are set during routing, before the app object is created below. +let app = {}; +// List of pipelines for the Grid view +let gridPipelines = []; +// List of pipelines for the filtered view +let filteredGridPipelines = []; +// List of (one) pipeline for the Card view. +let cardPipelines = []; + +// 2. Define some routes +// Each route should map to a component. The "component" can +// either be an actual component constructor created via +// `Vue.extend()`, or just a component options object. +// We'll talk about nested routes later. +const routes = [ + { path: '/', component: ThePipelineGrid, props: { pipelines: gridPipelines } }, + { path: '/filtered/:nameExpression', component: FilteredPipelineGrid, props: { filteredPipelines: filteredGridPipelines } }, + { path: '/card/:pipelineName', component: PipelineCard, props: { cardlines: cardPipelines } } +]; + +// 3. Create the router instance and pass the `routes` option +// You can pass in additional options here, but let's +// keep it simple for now. +const router = new VueRouter({ + routes // short for `routes: routes` }); -pipelineService.getPipelines(function (names) { - for (let i = 0; i < names.length; i++) { - app.pipelines.push(names[i]); +// Before each route, clear the ajaxSequencer in case we have a backlog of Ajax calls outstanding. +// We're switching routes, so we don't care about them anymore. +router.beforeEach((to, from, next) => { + ajaxSequencer.clear(); + next(); +}); + +// After each route, figure out what needs to be refreshed and how to go about doing it. +// For now, we re-fetch all pipelines when showing the grid, but we reload the entire page +// when showing an individual card. +router.afterEach((to, from) => { + // Cancel any interval from the previous route. + window.clearInterval(refreshId); + + // Show the loading indicator (even if just briefly). + app.loading = true; + + let refreshFunc = window.location.reload; + let refreshArgs = null; + + // Clear out the filter text field. We'll enter current filter value down below. + $('.js-filter-text').val(''); + + if (to.path === '/') { + fetchAllPipelines(); + refreshFunc = fetchAllPipelines; + } else if (to.path.match('^/filtered/')) { + $('.js-filter-text').val(to.params.nameExpression); + fetchFilteredPipelines(to.params.nameExpression); + refreshFunc = fetchFilteredPipelines; + refreshArgs = to.params.nameExpression; + } else if (to.path.match('^/card/')) { + fetchCardPipeline(to.params.pipelineName); + refreshFunc = fetchCardPipeline; + refreshArgs = to.params.pipelineName; + } + + if (refreshInterval) { + refreshId = window.setInterval(refreshFunc, refreshInterval, refreshArgs); + } +}); + +function fetchCardPipeline(pipelineName) { + // Show the loading indicator each time we refresh this card view. + app.loading = true; + pipelineService.getPipelineDetails(pipelineName) + .done((pipeline) => app.cardlines.splice(0, app.cardlines.length, pipeline)) + .always(() => app.loading = false); +} + +function replacePipelines(currentPipelines, names) { + // Find all current app.pipeline elements that have names in the returned (names) array, + // and use this list as the initial set of pipeline objects to display. + // Filter out (undefined) elements. Happens when app.pipelines doesn't have an entry for (name). Initial condition. + let pipelines = names.map((name) => currentPipelines[currentPipelines.findIndex((item) => item.name === name)]) +.filter((item) => !!item); + + for (let i = 0; i < names.length; i++) { + // Fetch each pipeline. + pipelineService.getPipelineDetails(names[i]).done((pipeline) => { + // We've got something to display, so stop the loading indicator. Doesn't matter if this is set to false many times. + app.loading = false; + + // Find the index of the element in the array, that has this name. + let pipelineIndex = pipelines.findIndex((item) => item.name === pipeline.name); + + if (pipelineIndex >= 0) { + // If found, replace the found item with the newly returned pipeline details. + pipelines[pipelineIndex] = pipeline + } else { + // Otherwise, push it. Either starting with an empty array, or a new pipeline has been added. + pipelines.push(pipeline); } + + // Sort the pipelines each time new details arrive. + pipelines = pipelines.sort(function(a, b) { + // Useful for testing. Randomize the order every time. + // return Math.random() - Math.random(); + return b.lastStatusChange - a.lastStatusChange; + }); + + // Replace the contents of app.pipelines with these new (sorted) pipelines. + currentPipelines.splice(0, currentPipelines.length, ...pipelines); + }); + } +} + +function fetchAllPipelines() { + // Navigating to the initial path. Fetch all pipeline data. + pipelineService.getPipelines().done((names) => { + replacePipelines(app.pipelines, names); + }); +} + +function fetchFilteredPipelines(nameExpression) { + // Fetch filtered pipeline data + pipelineService.getPipelines().done((names) => { + var regex = new RegExp(nameExpression); + filteredNames = names.filter((name) => regex.test(name)); + replacePipelines(app.filteredPipelines, filteredNames); + }); +} + +// Finally, create the Vue object. +app = new Vue({ + el: "#app", + router: router, + data: { + pipelines: gridPipelines, + filteredPipelines: filteredGridPipelines, + cardlines: cardPipelines, + loading: true + }, + methods: {} }); diff --git a/src/main/resources/static/js/pipelineService.js b/src/main/resources/static/js/pipelineService.js index 409e96b..14c97ee 100644 --- a/src/main/resources/static/js/pipelineService.js +++ b/src/main/resources/static/js/pipelineService.js @@ -1,46 +1,69 @@ -let PipelineService = function (jquery) { - - let getPipelines = function (responseHandler) { - jquery.ajax({ - dataType: "json", - url: "/pipelines", - success: function (response) { - const listOfPipelineNames = []; - for (let i = 0; i < response.length; i++) { - listOfPipelineNames.push(response[i].name); - } - responseHandler(listOfPipelineNames); - } - }); - }, - getPipelineDetailFromAWS = function (pipelineName, responseHandler) { - jquery.ajax({ - dataType: "json", - url: "/pipeline/" + pipelineName, - success: function (response) { - responseHandler(response); - } - }); - }, - parsePipelineState = function (stageState, commitMessage) { - return { - name: stageState.stageName, - latestStatus: stageState.actionStates[0].latestExecution.status.toLowerCase(), - lastStatusChange: stageState.actionStates[0].latestExecution.lastStatusChange, - externalExecutionUrl: stageState.actionStates[0].latestExecution.externalExecutionUrl, - commitMessage: commitMessage +let PipelineService = function (jquery, as) { + + // If no AjaxSequencer was passed in, use the jQuery instance directly. + as = as || jquery; + + function getPipelines() { + return as.get('/pipelines').then((response) => response.map((elem) => elem.name)); + } + + function getErrorMessageLinks(errorDetails) { + if (errorDetails && errorDetails.message) { + var urlRegex = /(https?:\/\/[^\s"]+)/g; + return errorDetails.message.match(urlRegex); + } + return []; + } + + function parsePipelineActionState(actionState) { + const currentRevision = actionState.currentRevision || {}; + const latestExecution = actionState.latestExecution || {}; + const status = latestExecution.status || ''; + const errorDetails = latestExecution.errorDetails || {}; + const errorMessageLinks = getErrorMessageLinks(errorDetails); + return { + name: actionState.actionName, + revisionId: currentRevision.revisionId, + latestStatus: status.toLowerCase(), + lastStatusChange: latestExecution.lastStatusChange, + externalExecutionUrl: latestExecution.externalExecutionUrl, + errorDetails: errorDetails.message, + errorMessageLinks: errorMessageLinks + }; + } + + function getPipelineDetails(pipelineName) { + let stages = []; + return as.get("/pipeline/" + pipelineName).then(function(response) { + let pipelineDetails = { + name: pipelineName, + commitMessage: response.commitMessage, + lastStatusChange: 0, + states: [] }; - }, - getPipelineDetails = function (pipelineName, responseHandler) { - let stages = []; - getPipelineDetailFromAWS(pipelineName, - function (response) { - for (let i = 0; i < response.stageStates.length; i++) { - stages.push(parsePipelineState(response.stageStates[i], response.commitMessage)); - } - responseHandler(stages); + + for (let i = 0; i < response.stageStates.length; i++) { + const stageState = response.stageStates[i]; + let stages = []; + for (let j=0; j < stageState.actionStates.length; j++) { + let actionState = stageState.actionStates[j]; + stages.push(parsePipelineActionState(actionState)); + } + const statusChanges = stages.map((stage) => stage.lastStatusChange || 0); + const lastStatusChange = Math.max.apply(Math, [Date.parse(statusChanges)]); + + pipelineDetails.states.push({ + name: stageState.stageName, + lastStatusChange: lastStatusChange, + stages: stages }); - }; + } + + const pipelineStatusChanges = pipelineDetails.states.map((state) => state.lastStatusChange); + pipelineDetails.lastStatusChange = Math.max.apply(Math, pipelineStatusChanges); + return pipelineDetails; + }); + } return { getPipelines: getPipelines, diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index dc0c6c2..eb0bd07 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,45 +1,226 @@ <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> + +<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - --> +<!-- Append "?refresh=NN" query param to refresh every NN seconds. --> +<!-- E.g. ?refresh=30 to refresh every 30 seconds. Default is 60. --> +<!-- Append "?static" query param to avoid automatic page refreshes. --> +<!-- "?static" takes precedence over "?refresh=". --> +<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - --> + <head> - <title>Dashboard + + + + + + CodePipeline Dashboard + + + + + + + + + + + + + + + + + + + - - -
-

Dashboard

-
Legend: - succeeded - in progress - failed + + + +
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
-
-
- - -
+ + + - + - + - + - + - + + + - \ No newline at end of file + +