HN github action dashboard

master
Lei OT 2 years ago
commit dafd87e973

@ -0,0 +1,19 @@
*.crt
*.key
.dockerignore
.git
.gitignore
.idea
.jshintrc
commit.sha
# dist
# dist/*
Dockerfile
#node_modules/*
README.md
.editorconfig
.browserslistrc
jsconfig.json
.env*
.env.local
.env.*.local

@ -0,0 +1,11 @@
PORT=8080
GITHUB_USERNAME=XXXXXXXX
GITHUB_APPID=000000
GITHUB_APP_PRIVATEKEY='-----BEGIN RSA PRIVATE KEY-----\nXXXXXXXXXXXXXXX\n-----END RSA PRIVATE KEY-----'
GITHUB_APP_CLIENTID=XXX.XXXXXXXXXXXXXXXXXX
GITHUB_APP_CLIENTSECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
GITHUB_APP_INSTALLATIONID=00000000
#DEBUG=action-dashboard:*
GITHUB_APP_WEBHOOK_SECRET=XXXXXX
GITHUB_APP_WEBHOOK_PORT=8081
#GITHUB_APP_WEBHOOK_PATH=/webhook

@ -0,0 +1,13 @@
module.exports = {
env: {
browser: true,
es2020: true,
},
parser: "@babel/eslint-parser",
parserOptions: {
ecmaVersion: 11,
requireConfigFile: false,
sourceType: "module",
},
rules: {},
};

@ -0,0 +1,68 @@
name: ci
on:
workflow_dispatch:
push:
# pull_request:
# branches: main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version-file: ".nvmrc"
- name: Install packages for server
run: npm ci --ignore-scripts
- name: Install Packages for client
run: npm ci --ignore-scripts
working-directory: ./client
- name: Run tests
run: npm run test
- name: Eliminate devDependencies
run: npm prune --production
- name: Build web client
run: DOCKER_BUILD=true npm run build
working-directory: ./client
- name: Docker meta
id: docker_meta
uses: crazy-max/ghaction-docker-meta@v1
with:
images: ghcr.io/${{ github.repository_owner }}/github-action-dashboard
tag-sha: true
tag-edge: true
tag-latest: true
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
#with:
# hack for https://github.com/docker/build-push-action/issues/126
#driver-opts: image=moby/buildkit:master
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_CR_PAT }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/github-action-dashboard:edge
cache-to: type=inline
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }} #this is for logging.

29
.gitignore vendored

@ -0,0 +1,29 @@
.DS_Store
node_modules
/dist
/client/dist
# local env files
.env
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
wallaby.conf.js
**/logs/**
*.pem

@ -0,0 +1 @@
v16.13.1

@ -0,0 +1,21 @@
FROM node:16-alpine as base
LABEL org.opencontainers.image.source=https://github.com/ChrisKinsman/github-action-dashboard
WORKDIR /github-action-dashboard
# ---- Dependencies
FROM base as dependencies
RUN apk add --no-cache --virtual .gyp python3 make g++ git openssh
#
# ---- npm ci production
FROM dependencies as npm
COPY package.json package-lock.json ./
COPY node_modules ./node_modules
RUN npm rebuild
# production stage & clean up
FROM base as release
ENV NODE_ENV production
COPY client/dist ./client/dist/
COPY actions.js configure.js getinstallationid.js github.js index.js routes.js runstatus.js webhooks.js ./
COPY --from=npm /github-action-dashboard/node_modules ./node_modules

@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) 2009-2014 TJ Holowaychuk <tj@vision-media.ca>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -0,0 +1,143 @@
# GitHub Action Dashboard
![ScreenShot](https://github.com/ChrisKinsman/github-action-dashboard/blob/main/docs/images/ActionDashboardScreenShot.png)
When our current CI/CD provider shutdown I found myself evaluating GitHub actions as an alternative. Great solution with one problem. There was no single pane of glass to see the status of all the builds in our GitHub organization. Instead you had to go into each repo, check the action status, etc.
I looked around for solutions to the problem and found very few. Meercode was a SaaS that was available but connecting it to my GitHub account at the time I tested it it required granting it permission to act on my behalf. I couldn't see a way that my employer would be cool with that.
A self hosted solution seemed like the way to go but I couldn't really find any. Surprising given the popularity of GitHub.
## Limitations
- Single organization/username. Currently the dashboard requires you to specify the organization or the username of the repositories which show on the dashboard. It doesn't support multiple organizations or usernames.
## How it works
- Upon startup all repositories for the organization/username are iterated.
- Each repository is checked for workflows.
- Each workflow has it's runs listed
- The most recent run for each branch is returned.
Every 15 minutes this process is repeated. Fifteen minutes was chosen so as to not hit GitHub API limits.
When you click the refresh button in the dashboard it refreshes all runs associated with that workflow across all branches. This is refreshed server side so that other consumers of the dashboard also see the update prior to the refresh of all data.
When a workflow_run webhook is received the the central data is updated and the update is sent to all clients to refresh their displays via websockets.
## Setup GitHub App
The dashboard runs as a GitHub App. It does not automatically register itself as a GitHub app. Automatic registration is difficult if the dashboard is private and not exposed on the internet. Instead you need to manually setup a GitHub app for your organization or username.
Steps:
- Go into the settings for your organization or username
- Click Developer Settings
- Click GitHub Apps
- Click New GitHub App
- Add an app name and homepage url
- Put your endpoint in for the webhook url - This is optional. If you don't configure this the dashboard lag action status. For testing you can use https://smee.io but in production you will likely have to look at a solution like https://ngrok.com or https://inlets.dev
- Put in a webhook secret
- Repository Permissions:
- Action: read-only
- Subscribe to events:
- Workflow run
- Where can this GibHub App be installed: Only on this account
- Should look like: ![General Settings Screen](https://github.com/ChrisKinsman/github-action-dashboard/blob/main/docs/images/ActionDashboardNewGitHubApp.png)
- Click Create GitHub App
- You should now be on the general settings page for the app
- Click Generate a new client secret and save off the client secret as it will disappear after you navigate off the page.
- Click Generate a private key. It will download a .pem file that you need to base64 encode.
- Should look like: ![General Settings Screen](https://github.com/ChrisKinsman/github-action-dashboard/blob/main/docs/images/ActionDashboardGeneralSettings.png)
- Change to Install App page: ![General Settings Screen](https://github.com/ChrisKinsman/github-action-dashboard/blob/main/docs/images/ActionDashboardInstall.png)
- Click Install
- You will get a permissions page like this: ![General Settings Screen](https://github.com/ChrisKinsman/github-action-dashboard/blob/main/docs/images/ActionDashboardPermissions.png)
- Click install
## Configuring Dashboard
The dashboard has all of it's parameters passed via environment variables.
### Variables
- PORT - Optional, defaults to 8080. Port site should run on.
- GITHUB_USERNAME or GITHUB_ORG - Only one is valid. If both are specified GITHUB_ORG takes precedence and the GITHUB_USERNAME is ignored.
- GITHUB_APPID - The AppId from the GitHub App general settings page.
- GITHUB_APP_PRIVATEKEY - The base64 encoded private key from the GitHub App general settings page.
- GITHUB_APP_CLIENTID - The client id from the GitHub App general settings page.
- GITHUB_APP_CLIENTSECRET - The client secret from the GitHub App general settings page.
- GITHUB_APP_INSTALLATIONID - Installation id that can be retrieved using steps in the next section.
- GITHUB_APP_WEBHOOK_SECRET - Optional. If you don't supply the dashboard will not setup webhooks and only update every 15 minutes.
- GITHUB_APP_WEBHOOK_PORT - Optional, defaults to 8081. If set to the same as PORT must also specify GITHUB_APP_WEBHOOK_PATH
- GITHUB_APP_WEBHOOK_PATH - Optional if WebHooks running on different port than main site, defaults to /, if running on the same port defaults to /webhook.
- LOOKBACK_DAYS - Optional, defaults to 7. Number of days to look in the past for workflow runs.
- DEBUG=action-dashboard:\* - Optional setting to help in debugging
### Installation Id
GitHub doesn't make the installation id super obvious in the UI. Here is how to obtain the installation id.
- Go into the settings for your organization or username
- Click Developer Settings
- Click GitHub Apps
- Click Configure on GitHub Action Dashboard
- In the URL, the digits after the slash are your installation id.
Alternatively I have provided a utility to obtain the installation id. You will need all the variables from the previous section with the exception of GITHUB_APP_INSTALLATIONID set to do this.
#### Option 1
Requires docker installed locally.
```bash
docker run --rm -t --env GITHUB_USERNAME=XXXXXXX --env GITHUB_APPID=XXXXXX --env GITHUB_APP_PRIVATEKEY=XXXXXXXXXXXXXXXXXXX --env GITHUB_APP_CLIENTID=XXX.XXXXXXXXXXXXXXXX --env GITHUB_APP_CLIENTSECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghcr.io/chriskinsman/github-action-dashboard:edge node getinstallationid.js
```
#### Option 2
Requires nodejs installed locally.
```bash
npm ci
GITHUB_USERNAME=XXXXXXX GITHUB_APPID=XXXXXX GITHUB_APP_PRIVATEKEY=XXXXXXXXXXXXXXXXXXXXX GITHUB_APP_CLIENTID=XXX.XXXXXXXXXXXXXXXX GITHUB_APP_CLIENTSECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX node getinstallationid.js
```
## Running Dashboard
#### Option 1
Requires docker installed locally.
```bash
docker run --rm -td -p 8080:8080 --env GITHUB_USERNAME=XXXXXXX --env GITHUB_APPID=XXXXXX --env GITHUB_APP_PRIVATEKEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX --env GITHUB_APP_CLIENTID=XXX.XXXXXXXXXXXXXXXX --env GITHUB_APP_CLIENTSECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX --env GITHUB_APP_INSTALLATIONID=XXXXXXX ghcr.io/chriskinsman/github-action-dashboard:edge node index.js
```
#### Option 2
Requires nodejs installed locally.
```bash
npm ci
cd client
npm ci
npm run build
cd ..
GITHUB_USERNAME=XXXXXXX GITHUB_APPID=XXXXXX GITHUB_APP_PRIVATEKEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX GITHUB_APP_CLIENTID=XXX.XXXXXXXXXXXXXXXX GITHUB_APP_CLIENTSECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX GITHUB_APP_INSTALLATIONID=XXXXXXX node index.js
```
Open your browser to http://localhost:8080
## Debugging tips
Debugging webhooks can be hard.
Couple tips:
1. Run the full app outside vue-cli
2. Use the smee-client to proxy the webhook:
```bash
smee --port 8081
```
Then take the proxy endpoint and update your GitHub App webhook with it

@ -0,0 +1,181 @@
const debug = require("debug")("action-dashboard:actions");
const _ = require("lodash");
const dayjs = require("dayjs");
const pLimit = require("p-limit");
class Actions {
constructor(gitHub, runStatus, lookbackDays) {
this._gitHub = gitHub;
this._runStatus = runStatus;
// Cache all workflows to speed up refresh
this._runs = [];
this._refreshingRuns = false;
this._lookbackDays = lookbackDays;
}
start() {
debug("Performing initial refreshRuns");
// Load the initial set
this.refreshRuns();
debug("Setting interval to refreshRuns at 15m");
// Refresh by default every fifteeen minutes
setInterval(this.refreshRuns, 1000 * 60 * 15);
}
async getMostRecentRuns(repoOwner, repoName, workflowId) {
try {
const daysAgo = dayjs().subtract(this._lookbackDays, "day");
const runs = await this._gitHub.listWorkflowRuns(
repoOwner,
repoName,
workflowId
);
if (runs.length > 0) {
const groupedRuns = _.groupBy(runs, "head_branch");
const rows = _.reduce(
groupedRuns,
(result, runs, branch) => {
debug(`branch`, branch);
if (daysAgo.isBefore(dayjs(runs[0].created_at))) {
debug(`adding run.id: ${runs[0].id}`);
result.push({
runId: runs[0].id,
repo: runs[0].repository.name,
owner: repoOwner,
workflowId: workflowId,
runNumber: runs[0].run_number,
workflow: runs[0].name,
branch: runs[0].head_branch,
sha: runs[0].head_sha,
message: runs[0].head_commit.message,
committer: runs[0].head_commit.committer.name,
status:
runs[0].status === "completed"
? runs[0].conclusion
: runs[0].status,
createdAt: runs[0].created_at,
updatedAt: runs[0].updated_at,
});
} else {
debug(
`skipping run.id: ${runs[0].id} created_at: ${runs[0].created_at}`
);
}
return result;
},
[]
);
debug(
`getting duration of runs owner: ${repoOwner}, repo: ${repoName}, workflowId: ${workflowId}`
);
// Get durations of runs
const limit = pLimit(10);
const getUsagePromises = rows.map((row) => {
return limit(async () => {
const usage = await this._gitHub.getUsage(
repoOwner,
repoName,
workflowId,
row.runId
);
if (usage?.run_duration_ms) {
row.durationMs = usage.run_duration_ms;
}
return row;
});
});
const rowsWithDuration = await Promise.all(getUsagePromises);
debug(
`most recent runs owner: ${repoOwner}, repo: ${repoName}, workflowId: ${workflowId}`,
rowsWithDuration
);
return rows;
} else {
return [];
}
} catch (e) {
console.error("Error getting runs", e);
return [];
}
}
mergeRuns(runs) {
// Merge into cache
runs.forEach((run) => {
debug(`merging run`, run);
const index = _.findIndex(this._runs, {
workflowId: run.workflowId,
branch: run.branch,
});
if (index >= 0) {
this._runs[index] = run;
} else {
this._runs.push(run);
}
this._runStatus.updatedRun(run);
});
debug("merged runs", this._runs);
}
refreshRuns = async () => {
// Prevent re-entrant calls
if (this._refreshingRuns) {
return;
}
debug("Starting refreshing runs");
try {
this._refreshingRuns = true;
const repos = await this._gitHub.listRepos();
for (const repo of repos) {
debug(`repo: ${repo.name}`);
const workflows = await this._gitHub.listWorkflowsForRepo(
repo.name,
repo.owner.login
);
if (workflows.length > 0) {
for (const workflow of workflows) {
debug(`workflow: ${workflow.name}`);
const runs = await this.getMostRecentRuns(
repo.owner.login,
repo.name,
workflow.id
);
// Not using apply or spread in case there are a large number of runs returned
this.mergeRuns(runs);
}
}
}
} catch (e) {
console.error("Error getting initial data", e);
} finally {
debug("Finished refreshing runs");
this._refreshingRuns = false;
}
};
async refreshWorkflow(repoOwner, repoName, workflowId) {
const runs = await this.getMostRecentRuns(repoOwner, repoName, workflowId);
this.mergeRuns(runs);
}
getInitialData() {
debug(`getInitialData this._runs.length: ${this._runs.length}`);
if (this._runs.length === 0 && !this._refreshingRuns) {
debug("getInitialData calling refreshRuns");
this.refreshRuns();
}
return this._runs;
}
}
module.exports = Actions;

23
client/.gitignore vendored

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2015",
"module": "esnext",
"baseUrl": "./",
"paths": {
"@/*": ["components/*"]
}
},
"include": [
"src/**/*.vue",
"src/**/*.js"
]
}

28173
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,52 @@
{
"name": "client",
"version": "1.6.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "DOCKER_BUILD=true vue-cli-service lint"
},
"dependencies": {
"axios": "^0.27.2",
"core-js": "^3.22.5",
"dayjs": "^1.11.2",
"lodash-es": "^4.17.21",
"socket.io-client": "^4.5.1",
"vue": "^2.6.14",
"vue-socket.io-extended": "^4.2.0",
"vuetify": "^2.6.6"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.15",
"@vue/cli-plugin-eslint": "^4.5.15",
"@vue/cli-service": "~4.5.15",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.32.13",
"sass-loader": "^10.0.0",
"vue-cli-plugin-vuetify": "~2.4.8",
"vue-template-compiler": "^2.6.14",
"vuetify-loader": "^1.7.3"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

@ -0,0 +1,37 @@
<template>
<v-app>
<v-app-bar app color="primary" dark>
<v-app-bar-title>{{ owner }} Action Dashboard</v-app-bar-title>
</v-app-bar>
<v-main>
<ActionDashboard />
</v-main>
</v-app>
</template>
<script>
import ActionDashboard from "./components/actiondashboard.vue";
import axios from "axios";
export default {
name: "App",
mounted() {
axios
.get("/api/owner")
.then((result) => {
console.log("Setting owner to " + result.data);
this.owner = result.data;
})
.catch((err) => {
console.error(err);
});
},
components: {
ActionDashboard,
},
data: () => ({
owner: "PlaceholderTitleForOwner",
}),
};
</script>

@ -0,0 +1,187 @@
<template>
<v-container :fluid="true">
<v-data-table
:headers="headers"
:items="runs"
item-key="name"
class="elevation-1"
:search="search"
:custom-filter="filterOnlyCapsText"
:disable-pagination="true"
:hide-default-footer="true"
:loading="loading"
loading-text="Loading runs..."
sort-by="createdAt"
:sort-desc="true"
>
<template v-slot:top>
<v-text-field v-model="search" label="Search" class="mx-4"></v-text-field>
</template>
<template v-slot:item.workflow="{ item }">
<a :href="`https://github.com/${item.owner}/${item.repo}/actions?query=workflow%3A${item.workflow}`" target="_blank">{{ item.workflow }}</a>
</template>
<template v-slot:item.message="{ item }">
<a :href="`https://github.com/${item.owner}/${item.repo}/actions/runs/${item.runId}`" target="_blank">{{ item.message }}</a>
</template>
<template v-slot:item.sha="{ item }">
<a :href="`https://github.com/${item.owner}/${item.repo}/commit/${item.sha}`" target="_blank">{{ item.sha.substr(0, 8) }}</a>
</template>
<template v-slot:item.status="{ item }">
<v-chip :color="getColor(item.status)">
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.createdAt="{ item }">
<div>
<div>{{ item.createdAt | formattedDate }}</div>
<div>{{item.createdAt | formattedTime}}</div>
</div>
</template>
<template v-slot:item.durationMs="{ item }">
{{ item.durationMs | formattedDuration }}
</template>
<template v-slot:item.actions="{ item }">
<v-icon small @click="refreshRun(item)"> mdi-refresh </v-icon>
</template>
</v-data-table>
</v-container>
</template>
<script>
import axios from "axios";
import findIndex from "lodash-es/findIndex";
import * as dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
dayjs.extend(duration);
export default {
sockets: {
updatedRun(run) {
console.log("updatedRun runId: " + run.runId);
const index = findIndex(this.runs, { workflowId: run.workflowId, branch: run.branch });
if (index >= 0) {
this.$set(this.runs, index, run);
} else {
this.runs.push(run);
}
},
},
mounted() {
this.getData();
},
data() {
return {
search: "",
runs: [],
loading: false,
};
},
computed: {
headers() {
return [
{ text: "Repository", align: "start", value: "repo" },
{ text: "Workflow", value: "workflow" },
{ text: "Branch", value: "branch" },
{ text: "Status", value: "status" },
{ text: "Commit", value: "sha" },
{ text: "Message", value: "message" },
{ text: "Committer", value: "committer" },
{ text: "Started", value: "createdAt", align: "right"},
{ text: "Duration", value: "durationMs", align: "right"},
{ text: "", value: "actions", sortable: false },
];
},
},
filters: {
formattedDate(val) {
if(val) {
return dayjs(val).format("YYYY-MM-DD");
}
else return val;
},
formattedTime(val) {
if(val) {
return dayjs(val).format("h:mm A")
}
},
formattedDuration(val) {
if(val) {
let format = "";
if(val >= 3.6e+6) {
format = "H[h] m[m] s[s]";
}
else if(val >= 60000 ) {
format = "m[m] s[s]";
}
else {
format = "s[s]";
}
return dayjs.duration(val).format(format);
}
else return val;
}
},
methods: {
getData() {
this.loading = true;
axios
.get("/api/initialData")
.then((result) => {
console.log("getData results");
this.runs = result.data;
})
.catch((err) => {
console.log("getData error");
console.error(err);
})
.finally(() => {
console.log("getData finally");
this.loading = false;
});
},
refreshRun(run) {
// This
run.status = "Refreshing";
// Get all new runs for workflow_id
axios.get(`/api/runs/${run.owner}/${run.repo}/${run.workflowId}`).catch((err) => {
console.log("refreshRun", err);
});
},
filterOnlyCapsText(value, search) {
return value != null && search != null && typeof value === "string" && value.toString().indexOf(search) !== -1;
},
getColor(status) {
switch (status) {
case "success":
return "green";
case "failure":
return "red";
case "in_progress":
case "queued":
return "yellow";
default:
return "transparent";
}
},
},
};
</script>
<style lang="scss">
.v-data-table-header {
th {
white-space: nowrap;
}
th:nth-child(8) {
min-width: 120px;
}
}
</style>

@ -0,0 +1,12 @@
import Vue from 'vue'
import App from './App.vue'
import vuetify from './plugins/vuetify';
import vueSocketIoExtended from './plugins/vue-socket-io-extended';
Vue.config.productionTip = false
new Vue({
vueSocketIoExtended,
vuetify,
render: h => h(App)
}).$mount('#app')

@ -0,0 +1,15 @@
import Vue from 'vue';
import VueSocketIOExt from 'vue-socket.io-extended';
import { io } from 'socket.io-client';
const socket = io();
Vue.use(VueSocketIOExt, socket);
export default {
sockets: {
connect() {
console.log('socket connected');
}
}
}

@ -0,0 +1,7 @@
import Vue from 'vue';
import Vuetify from 'vuetify/lib/framework';
Vue.use(Vuetify);
export default new Vuetify({
});

@ -0,0 +1,19 @@
const configureAPI = require('../configure');
module.exports = {
chainWebpack: config => {
config.plugin('html').tap(args => {
args[0].title = "Action Dashboard"
return args;
})
},
devServer: {
before: configureAPI.before,
// Can't figure out how to connect up socket.io as part of webpack devServer
//after: configureAPI.after
},
transpileDependencies: [
'vuetify'
]
};

@ -0,0 +1,94 @@
const bodyParser = require("body-parser");
const debug = require("debug")("action-dashboard:configure");
const express = require("express");
const path = require("path");
const Actions = require("./actions");
const GitHub = require("./github");
const Routes = require("./routes");
const RunStatus = require("./runstatus");
const WebHooks = require("./webhooks");
const baseDir = path.basename(process.cwd());
// Handle when server is started from vue-cli vs root
if (baseDir === "client") {
debug("started from vue-cli");
require("dotenv").config({ path: path.resolve(process.cwd(), "../.env") });
}
// Handle when server is started from
else {
debug("started from index.js");
require("dotenv").config();
}
debug("env", process.env);
const {
PORT = 8080,
LOOKBACK_DAYS = 7,
GITHUB_APPID,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID,
GITHUB_APP_WEBHOOK_PORT = 8081,
GITHUB_APP_WEBHOOK_SECRET,
GITHUB_APP_WEBHOOK_PATH = "/",
GITHUB_ORG,
GITHUB_USERNAME,
} = process.env;
// Handles newlines \n in private key
const GITHUB_APP_PRIVATEKEY = Buffer.from(
process.env.GITHUB_APP_PRIVATEKEY || "",
"base64"
).toString("utf-8");
// For sharing runStatus across before/after stages
let _runStatus = null;
module.exports = {
before: (app) => {
debug("configure before");
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
_runStatus = new RunStatus();
const actions = new Actions(gitHub, _runStatus, LOOKBACK_DAYS);
const routes = new Routes(
actions,
process.env.GITHUB_ORG || process.env.GITHUB_USERNAME
);
const router = express.Router();
routes.attach(router);
app.use(bodyParser.json());
app.use("/api", router);
const webhooks = new WebHooks(
PORT,
GITHUB_APP_WEBHOOK_SECRET,
GITHUB_APP_WEBHOOK_PORT,
GITHUB_APP_WEBHOOK_PATH,
gitHub,
actions,
app
);
// Start everything
actions.start();
webhooks.start();
},
after: (app, server) => {
debug("configure after");
// Attach socket.io to server
_runStatus.start(server);
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

@ -0,0 +1,40 @@
const fs = require('fs');
var date = new Date();
var dat= date.getDate()+"-"+date.getMonth()+"-"+date.getFullYear();
var err_log = "./pm2/logs/err.log";
var out_log = "./pm2/logs/out.log";
fs.mkdirSync(`./pm2/logs/${dat}`, { recursive: true });
err_log = './pm2/logs/' + dat + '/error.log';
out_log = './pm2/logs/' + dat + '/output.log';
combined_log = './pm2/logs/' + dat + '/combined.log';
// fs.accessSync("./pm2/logs/${dat}",(err) => {
// if (!err) {
// console.log('in');
// err_log = './pm2/logs/' + date.toISOString() + '/error.log';
// out_log = './pm2/logs/' + date.toISOString() + '/output.log';
// combined_log = './pm2/logs/' + date.toISOString() + '/combined.log';
// } else {
// console.error(err)};
// console.log("created succesfully");
// });
module.exports = {
apps : [{
name: 'action dashboard',
script: 'index.js',
max_memory_restart: "2G",
merge_logs: true,
max_restarts: 20,
error_file : err_log,
out_file : out_log
// instances: 1,
// watch: '.'
}],
};

@ -0,0 +1,29 @@
require('dotenv').config()
const { createAppAuth } = require("@octokit/auth-app");
const { Octokit } = require("@octokit/rest");
const _appId = process.env.GITHUB_APPID;
// Handles newlines \n in private key
const _privateKey = Buffer.from(process.env.GITHUB_APP_PRIVATEKEY || "", "base64").toString("utf-8");
const _clientId = process.env.GITHUB_APP_CLIENTID;
const _clientSecret = process.env.GITHUB_APP_CLIENTSECRET;
const octokit = new Octokit({
auth: {
appId: _appId,
privateKey: _privateKey,
clientId: _clientId,
clientSecret: _clientSecret,
},
authStrategy: createAppAuth
});
octokit.apps.listInstallations()
.then(response => {
response.data.forEach((installation) => {
console.log(`Account: ${installation.account.login}, installation id: ${installation.id}`);
});
})
.catch(err => {
console.error(err);
});

@ -0,0 +1,123 @@
const { createAppAuth } = require("@octokit/auth-app");
const { throttling } = require("@octokit/plugin-throttling");
const { retry } = require("@octokit/plugin-retry");
const { Octokit } = require("@octokit/rest");
const debug = require("debug")("action-dashboard:github");
class GitHub {
constructor(
_org,
_user,
_appId,
_privateKey,
_clientId,
_clientSecret,
_installationId
) {
this._org = _org;
this._user = _user;
this._appId = _appId;
this._privateKey = _privateKey;
this._clientId = _clientId;
this._clientSecret = _clientSecret;
this._installationId = _installationId;
const MyOctoKit = Octokit.plugin(throttling).plugin(retry);
this._octokit = new MyOctoKit({
auth: {
appId: _appId,
privateKey: _privateKey,
clientId: _clientId,
clientSecret: _clientSecret,
installationId: _installationId,
},
authStrategy: createAppAuth,
throttle: {
onRateLimit: (retryAfter, options) => {
console.error(
`Request quota exhausted for request ${options.method} ${options.url}`
);
if (options.request.retryCount === 0) {
// only retries once
console.error(`Retrying after ${retryAfter} seconds!`);
return true;
}
},
onAbuseLimit: (retryAfter, options) => {
console.error(
`Abuse detected for request ${options.method} ${options.url}`
);
},
},
});
// Allows us to use the dashboard for user based repos or org based repos
this._listRepos = this._org
? this._octokit.repos.listForOrg
: this._octokit.repos.listForUser; // listForAuthenticatedUser
this._owner = this._org ? { org: this._org } : { username: this._user };
debug("Using owner:", this._owner);
}
async listRepos() {
try {
const repos = await this._octokit.paginate(this._listRepos, this._owner);
const allRepos = ['data.chinahighlights.com', 'information-system', 'chinahighlights.com', 'asiahighlights.com','globalhighlights.com','trainspread.com'];
return allRepos.map(ele => ({ name: ele, owner: {login: 'hainatravel'}}));
return repos;
} catch (e) {
console.error("Error getting repos", e);
return [];
}
}
async listWorkflowsForRepo(repoName, repoOwner) {
try {
const workflows = await this._octokit.paginate(
this._octokit.actions.listRepoWorkflows,
{ repo: repoName, owner: repoOwner }
);
return workflows;
} catch (e) {
console.error("Error getting workflows", e);
return [];
}
}
async getUsage(repoOwner, repoName, workflowId, run_id) {
try {
const usage = await this._octokit.actions.getWorkflowRunUsage({
repo: repoName,
owner: repoOwner,
workflow_id: workflowId,
run_id: run_id,
});
return usage.data;
} catch (e) {
console.error("Error getting usage", e);
return null;
}
}
async listWorkflowRuns(repoOwner, repoName, workflowId) {
try {
const runs = await this._octokit.paginate(
this._octokit.actions.listWorkflowRuns,
{
repo: repoName,
owner: repoOwner,
workflow_id: workflowId,
}
);
return runs;
} catch (e) {
console.error("Error getting runs", e);
return null;
}
}
}
module.exports = GitHub;

@ -0,0 +1,33 @@
try {
const { resolve } = require('path');
const history = require('connect-history-api-fallback');
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const configureAPI = require('./configure');
const { PORT = 8080 } = process.env;
// API
configureAPI.before(app);
configureAPI.after(app, server);
// UI
const publicPath = resolve(__dirname, './client/dist');
const staticConf = { maxAge: '1y', etag: false };
app.use(history());
app.use(express.static(publicPath, staticConf));
app.get('/', function (req, res) {
res.render(path.join(__dirname + '/client/dist/index.html'))
});
// Go
server.listen(PORT, () => console.log(`Action Dashboard running on port ${PORT}`));
}
catch (e) {
console.error('Error on startup');
console.error(e);
}

@ -0,0 +1,196 @@
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
require("dotenv").config();
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/86/97grlb756tlgysggzhl15g9m0000gr/T/jest_e0",
// Automatically clear mock calls, instances and results before every test
// clearMocks: false,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: ["/tests/integration/setEnvVars.js"],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

8920
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,40 @@
{
"name": "github-action-dashboard",
"version": "1.6.0",
"description": "",
"main": "index.js",
"scripts": {
"test:integration": "DEBUG=action-dashboard,-not_this jest ./tests/integration --silent",
"test": "DEBUG=action-dashboard,-not_this jest ./tests/unit --silent",
"serve": "./node_modules/nodemon/bin/nodemon.js index.js --ignore 'client/*.js' --exec 'npm run lint && node'",
"lint": "./node_modules/.bin/eslint '**/*.js' --ignore-pattern 'client/dist/' "
},
"keywords": [],
"author": "",
"license": "MIT",
"comments": {
"p-limit": "Stuck on 3.1.0 until we move to ESM"
},
"dependencies": {
"@octokit/auth-app": "^3.6.1",
"@octokit/plugin-retry": "^3.0.9",
"@octokit/plugin-throttling": "^3.6.2",
"@octokit/rest": "^18.12.0",
"@octokit/webhooks": "^9.24.0",
"body-parser": "^1.20.0",
"connect-history-api-fallback": "^1.6.0",
"dayjs": "^1.11.2",
"debug": "^4.3.4",
"dotenv": "^16.0.1",
"express": "^4.18.1",
"jest": "^28.1.0",
"lodash": "^4.17.21",
"p-limit": "^3.1.0",
"socket.io": "^4.5.1"
},
"devDependencies": {
"@babel/eslint-parser": "^7.17.0",
"axios": "^0.27.2",
"eslint": "^8.16.0"
}
}

@ -0,0 +1,31 @@
const debug = require("debug")("action-dashboard:routes");
class Routes {
constructor(actions, owner) {
this._owner = owner;
this._actions = actions;
}
attach(router) {
router.get("/owner", (req, res, next) => {
debug(`/owner ${this._owner}`);
res.send(this._owner);
});
router.get("/initialData", (req, res, next) => {
const initialData = this._actions.getInitialData();
res.send(initialData);
});
router.get("/runs/:owner/:repo/:workflow_id", (req, res, next) => {
this._actions.refreshWorkflow(
req.params.owner,
req.params.repo,
parseInt(req.params.workflow_id)
);
res.send();
});
}
}
module.exports = Routes;

@ -0,0 +1,21 @@
const debug = require("debug")("action-dashboard:runstatus");
class RunStatus {
start(server) {
debug("initializing");
const io = require("socket.io")(server);
io.on("connection", (client) => {
debug("connected");
this._client = client;
});
}
updatedRun(run) {
if (this._client) {
debug(`emitting updatedRun: `, run);
this._client.emit("updatedRun", run);
}
}
}
module.exports = RunStatus;

@ -0,0 +1,191 @@
const { TestWatcher } = require("jest");
const GitHub = require("../../github");
// Requires environment variables to be set to run tests
// In local environment this is set out of band via wallaby.conf.js
// In GitHub environment this is set via GitHub secrets
const {
LOOKBACK_DAYS = 7,
GITHUB_APPID,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID,
GITHUB_APP_WEBHOOK_PORT = 8081,
GITHUB_APP_WEBHOOK_SECRET,
GITHUB_ORG,
GITHUB_USERNAME,
} = process.env;
// Handles newlines \n in private key
const GITHUB_APP_PRIVATEKEY = Buffer.from(
process.env.GITHUB_APP_PRIVATEKEY || "",
"base64"
).toString("utf-8");
test("GitHub - Environment", () => {
expect(GITHUB_APP_PRIVATEKEY).toBeTruthy();
expect(GITHUB_APPID).toBeTruthy();
expect(GITHUB_APP_CLIENTID).toBeTruthy();
expect(GITHUB_APP_CLIENTSECRET).toBeTruthy();
expect(GITHUB_APP_INSTALLATIONID).toBeTruthy();
expect(GITHUB_USERNAME).toBeTruthy();
});
test("GitHub - listRepos", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const repos = await gitHub.listRepos();
expect(repos).toBeTruthy();
expect(repos.length > 1).toBeTruthy();
});
test("GitHub - listRepos - Error", async () => {
const gitHub = new GitHub(
"XYZ",
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const repos = await gitHub.listRepos();
expect(repos).toBeTruthy();
expect(repos).toHaveLength(0);
});
test("GitHub - listWorkflowsForRepo", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const workflows = await gitHub.listWorkflowsForRepo(
"github-action-dashboard",
"chriskinsman"
);
expect(workflows).toBeTruthy();
expect(workflows.length > 0).toBeTruthy();
});
test("GitHub - listWorkflowsForRepo Error", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const workflows = await gitHub.listWorkflowsForRepo(
"github-action-dashboard-missing",
"chriskinsman"
);
expect(workflows).toBeTruthy();
expect(workflows).toHaveLength(0);
});
test("GitHub - listWorkflowRuns", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const runs = await gitHub.listWorkflowRuns(
"chriskinsman",
"github-action-dashboard",
"5777275"
);
expect(runs).toBeTruthy();
expect(runs.length > 0).toBeTruthy();
});
test("GitHub - listWorkflowRuns Error", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const runs = await gitHub.listWorkflowRuns(
"chriskinsman",
"github-action-dashboard",
"23"
);
expect(runs).toBeFalsy();
});
test("GitHub - getUsage", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const usage = await gitHub.getUsage(
"chriskinsman",
"github-action-dashboard",
"5777275",
"1511883909"
);
expect(usage).toBeTruthy();
expect(usage.run_duration_ms).toBeTruthy();
});
test("GitHub - getUsage Error", async () => {
const gitHub = new GitHub(
GITHUB_ORG,
GITHUB_USERNAME,
GITHUB_APPID,
GITHUB_APP_PRIVATEKEY,
GITHUB_APP_CLIENTID,
GITHUB_APP_CLIENTSECRET,
GITHUB_APP_INSTALLATIONID
);
const usage = await gitHub.getUsage(
"chriskinsman",
"github-action-dashboard",
"5777275",
"12"
);
expect(usage).toBeFalsy();
});

@ -0,0 +1,216 @@
const Actions = require("../../actions");
const mockData = require("./mock_data");
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
test("Actions - Start", () => {
jest.useFakeTimers();
const gitHub = require("../../github");
const actions = new Actions(gitHub);
const refreshRuns = jest
.spyOn(actions, "refreshRuns")
.mockImplementation(() => {});
actions.start();
expect(refreshRuns.mock.calls.length).toBe(1);
jest.advanceTimersByTime(1000 * 60 * 16);
expect(refreshRuns.mock.calls.length).toBe(2);
});
test("Actions - getMostRecentRuns Empty", async () => {
jest.mock("../../github");
const GitHub = require("../../github");
const listWorkflowRuns = jest.fn(async () => {
return [];
});
GitHub.mockImplementation(() => {
return {
listWorkflowRuns: listWorkflowRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub, null, 7);
const runs = await actions.getMostRecentRuns(
"ChrisKinsman",
"github-action-dashboard",
""
);
expect(runs).toBeTruthy();
expect(runs).toHaveLength(0);
expect(listWorkflowRuns.mock.calls).toHaveLength(1);
});
test("Actions - getMostRecentRuns Error", async () => {
jest.mock("../../github");
const GitHub = require("../../github");
const listWorkflowRuns = jest.fn(async () => {
throw new Error("Foo");
});
GitHub.mockImplementation(() => {
return {
listWorkflowRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub, null, 7);
const runs = await actions.getMostRecentRuns(
"ChrisKinsman",
"github-action-dashboard",
""
);
expect(runs).toBeTruthy();
expect(runs).toHaveLength(0);
});
test("Actions - getMostRecentRuns With Data", async () => {
jest.mock("../../github");
const GitHub = require("../../github");
const listWorkflowRuns = jest.fn(async () => {
const mockRuns = [...mockData.runs];
return mockRuns;
});
const getUsage = jest.fn(async () => {
return { run_duration_ms: 10000 };
});
GitHub.mockImplementation(() => {
return {
listWorkflowRuns,
getUsage,
};
});
const gitHub = new GitHub();
// Long lookback for our test data
const actions = new Actions(gitHub, null, 600);
const runs = await actions.getMostRecentRuns(
"ChrisKinsman",
"github-action-dashboard",
""
);
console.dir(runs);
expect(runs).toBeTruthy();
expect(runs.length > 0).toBeTruthy();
});
test("Actions - getInitialData", async () => {
const gitHub = require("../../github");
const actions = new Actions(gitHub);
const refreshRuns = jest
.spyOn(actions, "refreshRuns")
.mockImplementation(() => {});
actions.getInitialData();
expect(refreshRuns.mock.calls.length).toBe(1);
});
test("Actions - refreshWorkflow", async () => {
const gitHub = require("../../github");
const actions = new Actions(gitHub);
const getMostRecentRuns = jest
.spyOn(actions, "getMostRecentRuns")
.mockImplementation(async () => {
return [];
});
const mergeRuns = jest.spyOn(actions, "mergeRuns").mockImplementation(() => {
return;
});
await actions.refreshWorkflow();
expect(getMostRecentRuns.mock.calls.length).toBe(1);
expect(mergeRuns.mock.calls.length).toBe(1);
});
test("Actions - mergeRuns", () => {
const mockRuns = [...mockData.runs];
const gitHub = require("../../github");
const RunStatus = require("../../runstatus");
const runStatus = new RunStatus();
const updatedRun = jest
.spyOn(runStatus, "updatedRun")
.mockImplementation(() => {
return;
});
const actions = new Actions(gitHub, runStatus);
actions.mergeRuns(mockRuns);
expect(updatedRun.mock.calls).toHaveLength(mockRuns.length);
});
test("Actions - refreshRuns", async () => {
jest.mock("../../github");
const GitHub = require("../../github");
const listRepos = jest.fn(async () => {
return [...mockData.repos];
});
const listWorkflowsForRepo = jest.fn(async () => {
return [...mockData.workflows];
});
const listWorkflowRuns = jest.fn(async () => {
return [...mockData.runs];
});
GitHub.mockImplementation(() => {
return {
listRepos,
listWorkflowsForRepo,
listWorkflowRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub);
await actions.refreshRuns();
expect(listRepos.mock.calls).toHaveLength(1);
expect(listWorkflowsForRepo.mock.calls).toHaveLength(1);
expect(listWorkflowRuns.mock.calls.length > 0).toBeTruthy();
});
test("Actions - refreshRuns Error", async () => {
// Setup
jest.mock("../../github");
const GitHub = require("../../github");
const listRepos = jest.fn(async () => {
throw new Error("foo");
});
const listWorkflowsForRepo = jest.fn(async () => {
return [...mockData.workflows];
});
const listWorkflowRuns = jest.fn(async () => {
return [...mockData.runs];
});
GitHub.mockImplementation(() => {
return {
listRepos,
listWorkflowsForRepo,
listWorkflowRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub);
// Test
await actions.refreshRuns();
// Assertions
expect(listRepos.mock.calls).toHaveLength(1);
expect(listWorkflowsForRepo.mock.calls).toHaveLength(0);
expect(listWorkflowRuns.mock.calls).toHaveLength(0);
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,40 @@
const { createServer } = require("http");
const { Server } = require("socket.io");
const Client = require("../../client/node_modules/socket.io-client");
const RunStatus = require("../../runstatus");
let httpServer;
let port;
let io;
let runStatus;
beforeAll((done) => {
const httpServer = createServer();
io = new Server(httpServer);
httpServer.listen(() => {
port = httpServer.address().port;
runStatus = new RunStatus();
runStatus.start(httpServer);
done();
});
});
afterAll(() => {
io.close();
});
test("RunStatus - Connect", (done) => {
// Create a client
const clientSocket = new Client(`http://localhost:${port}`);
clientSocket.on("connect", () => {
clientSocket.on("updatedRun", (run) => {
expect(run.runId).toBe(123);
clientSocket.close();
done();
});
// Emit the message
runStatus.updatedRun({ runId: 123 });
});
});

@ -0,0 +1,238 @@
const WebHooks = require("../../webhooks");
const axios = require("axios").default;
const { Webhooks: OctoWebhooks } = require("@octokit/webhooks");
const mockData = require("./mock_data");
test("WebHooks - Init Disabled", () => {
const webHooks = new WebHooks(8080, null, 8081, "/", null, null, null);
expect(webHooks._enabled).toBeFalsy();
});
test(`WebHooks - Init Default`, () => {
const webHooks = new WebHooks(8080, "XXXXX", 8081, "/", null, null, null);
expect(webHooks._sitePort).toBe(8080);
expect(webHooks._webhookPort).toBe(8081);
expect(webHooks._defaultPath).toBe("/");
expect(webHooks._path).toBe("/");
expect(webHooks._secret).toBe("XXXXX");
expect(webHooks._enabled).toBeTruthy();
});
test(`WebHooks - Init Same Port`, () => {
// Setup
jest.mock("express");
const Express = require("express");
const use = jest.fn(() => {});
Express.mockImplementation(() => {
return {
use,
};
});
const express = new Express();
const webHooks = new WebHooks(
8080,
"XXXXX",
8080,
"/webhook",
null,
null,
express
);
expect(webHooks._sitePort).toBe(8080);
expect(webHooks._webhookPort).toBe(8080);
expect(webHooks._defaultPath).toBe("/webhook");
expect(webHooks._path).toBe("/webhook");
expect(webHooks._secret).toBe("XXXXX");
expect(webHooks._enabled).toBeTruthy();
webHooks.start();
expect(use.mock.calls.length).toBe(1);
webHooks.stop();
});
test(`WebHooks - Init Same Port bad path`, () => {
expect(() => {
const webHooks = new WebHooks(8080, "XXXX", 8080, "/", null, null, null);
}).toThrow();
});
describe(`WebHooks - HTTP Tests`, () => {
let webHooks;
let octoWebhooks;
let workflowRun;
const secret = "XXXXXXX";
const webHookPort = 8081;
beforeEach(() => {
octoWebhooks = new OctoWebhooks({ secret: secret });
webHooks = new WebHooks(8080, secret, webHookPort, "/", null, null, null);
workflowRun = jest
.spyOn(webHooks, "workflowRun")
.mockImplementation(() => {});
webHooks.start();
});
afterEach(() => {
webHooks.stop();
jest.restoreAllMocks();
});
test(`Ping`, async () => {
const response = await axios.get(`http://localhost:${webHookPort}/ping`);
expect(response.status).toBe(200);
});
test(`Workflow_Run Message`, async () => {
const sig = await octoWebhooks.sign(mockData.webHooks[0].payload);
const response = await axios.post(
`http://localhost:${webHookPort}/`,
mockData.webHooks[0].payload,
{
headers: {
"X-GitHub-Event": mockData.webHooks[0].name,
"X-GitHub-Delivery": mockData.webHooks[0].id,
"X-Hub-Signature-256": sig,
"Content-Type": "application/json",
},
}
);
expect(response.status).toBe(200);
expect(workflowRun.mock.calls.length).toBe(1);
});
});
test(`WebHooks - workflowRun Completed No Usage`, async () => {
jest.mock("../../github");
const GitHub = require("../../github");
const getUsage = jest.fn(async () => {
return null;
});
GitHub.mockImplementation(() => {
return {
getUsage,
};
});
jest.mock("../../actions");
const Actions = require("../../actions");
const mergeRuns = jest.fn(() => {});
Actions.mockImplementation(() => {
return {
mergeRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub);
webHooks = new WebHooks(
8080,
"XXXX",
8080,
"/webhook",
gitHub,
actions,
null
);
await webHooks.workflowRun(mockData.webHooks[0]);
expect(getUsage.mock.calls.length).toBe(1);
expect(mergeRuns.mock.calls.length).toBe(1);
});
test(`WebHooks - workflowRun Completed Usage`, async () => {
const durationMs = 1234;
jest.mock("../../github");
const GitHub = require("../../github");
const getUsage = jest.fn(async () => {
return { run_duration_ms: durationMs };
});
GitHub.mockImplementation(() => {
return {
getUsage,
};
});
jest.mock("../../actions");
const Actions = require("../../actions");
const mergeRuns = jest.fn(() => {});
Actions.mockImplementation(() => {
return {
mergeRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub);
webHooks = new WebHooks(
8080,
"XXXX",
8080,
"/webhook",
gitHub,
actions,
null
);
await webHooks.workflowRun(mockData.webHooks[0]);
expect(getUsage.mock.calls.length).toBe(1);
expect(mergeRuns.mock.calls.length).toBe(1);
expect(mergeRuns.mock.calls[0][0][0].durationMs).toBe(durationMs);
});
test(`WebHooks - workflowRun Pending`, async () => {
jest.mock("../../github");
const GitHub = require("../../github");
const getUsage = jest.fn(async () => {
return null;
});
GitHub.mockImplementation(() => {
return {
getUsage,
};
});
jest.mock("../../actions");
const Actions = require("../../actions");
const mergeRuns = jest.fn(() => {});
Actions.mockImplementation(() => {
return {
mergeRuns,
};
});
const gitHub = new GitHub();
const actions = new Actions(gitHub);
webHooks = new WebHooks(
8080,
"XXXX",
8080,
"/webhook",
gitHub,
actions,
null
);
const pending = { ...mockData.webHooks[0] };
pending.payload.workflow_run.status = "pending";
await webHooks.workflowRun(pending);
expect(getUsage.mock.calls.length).toBe(0);
expect(mergeRuns.mock.calls.length).toBe(1);
});

@ -0,0 +1,144 @@
const debug = require("debug")("action-dashboard:webhooks");
const { Webhooks, createNodeMiddleware } = require("@octokit/webhooks");
const http = require("http");
class WebHooks {
constructor(
sitePort,
secret,
webhookPort,
webhookPath,
gitHub,
actions,
expressApp
) {
if (secret) {
this._secret = secret;
this._webhookPort = webhookPort;
this._sitePort = sitePort;
this._gitHub = gitHub;
this._actions = actions;
if (sitePort === webhookPort) {
this._defaultPath = "/webhook";
} else {
this._defaultPath = "/";
}
this._path = webhookPath || this._defaultPath;
// Fail in the case that the ports for the main site and webhooks
// are the same and the path is explicitly set to /
if (sitePort === webhookPort && this._path === "/") {
throw new Error(
"Path cannot be / when the webhooks are running on the same port as the main site"
);
}
this._expressApp = expressApp;
this._enabled = true;
}
}
start() {
if (this._enabled) {
debug(
`Setting up webhooks port: ${this._webhookPort}, path: ${this._path}`
);
// OctoKit webhooks, not this module
const webhooks = new Webhooks({
secret: this._secret,
});
webhooks.onError((error) => {
console.dir(error);
// console.error(
// `Webhook error occured in "${error.event.name} handler: ${error.stack}"`
// );
});
const middleware = createNodeMiddleware(webhooks, { path: this._path });
webhooks.on("workflow_run", this.workflowRun);
if (this._sitePort !== this._webhookPort) {
this._server = http
.createServer((req, res) => {
debug(`received request path: ${req.url}`);
if (req.url === "/ping") {
debug("ping");
res.statusCode = 200;
res.end();
} else {
middleware(req, res);
}
})
.listen({ port: this._webhookPort }, () => {
console.log(
`Listening for webhooks on ${this._webhookPort} at ${this._path}`
);
});
} else {
this._expressApp.use(this._path, middleware);
console.log(
`Listening for webhooks on ${this._webhookPort} at ${this._path}`
);
}
} else {
debug("Webhooks disabled");
}
}
// Mainly used by testing functions to cleanly shutdown web server
stop() {
if (this._enabled && this._server && this._server.listening) {
this._server.close();
}
}
workflowRun = async ({ id, name, payload }) => {
try {
debug(`workflow_run received id: ${id}, name: ${name}`, payload);
let usage = null;
if (payload.workflow_run.status === "completed") {
debug(`getting usage for id: ${id}, name: ${name}`);
usage = await this._gitHub.getUsage(
payload.workflow_run.repository.owner.login,
payload.workflow_run.repository.name,
payload.workflow_run.workflow_id,
payload.workflow_run.id
);
}
debug(`merging runs for id: ${id}, name: ${name}`);
this._actions.mergeRuns([
{
runId: payload.workflow_run.id,
repo: payload.workflow_run.repository.name,
owner: payload.workflow_run.repository.owner.login,
workflowId: payload.workflow_run.workflow_id,
runNumber: payload.workflow_run.run_number,
workflow: payload.workflow_run.name,
branch: payload.workflow_run.head_branch,
sha: payload.workflow_run.head_sha,
message: payload.workflow_run.head_commit.message,
committer: payload.workflow_run.head_commit.committer.name,
status:
payload.workflow_run.status === "completed"
? payload.workflow_run.conclusion
: payload.workflow_run.status,
createdAt: payload.workflow_run.created_at,
updatedAt: payload.workflow_run.updated_at,
durationMs: usage?.run_duration_ms,
},
]);
debug(`runs merged for id: ${id}, name: ${name}`);
} catch (e) {
console.dir(e);
console.error(
`Error processing workflow_run received id: ${id}, name: ${name}`,
payload
);
}
};
}
module.exports = WebHooks;
Loading…
Cancel
Save