├── frontend ├── .prettierignore ├── tests │ └── unit │ │ ├── __mocks__ │ │ └── styleMock.js │ │ ├── .eslintrc.js │ │ ├── __snapshots__ │ │ ├── Dial.spec.js.snap │ │ ├── Spinner.spec.js.snap │ │ ├── About.spec.js.snap │ │ ├── Monitor.spec.js.snap │ │ ├── Home.spec.js.snap │ │ ├── Info.spec.js.snap │ │ └── App.spec.js.snap │ │ ├── Home.spec.js │ │ ├── About.spec.js │ │ ├── Spinner.spec.js │ │ ├── Dial.spec.js │ │ ├── App.spec.js │ │ ├── Monitor.spec.js │ │ └── Info.spec.js ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── assets │ │ ├── logo.png │ │ ├── error.png │ │ ├── container.png │ │ ├── octocat.png │ │ └── go.svg │ ├── components │ │ ├── Error.vue │ │ ├── About.vue │ │ ├── Spinner.vue │ │ ├── Home.vue │ │ ├── Dial.vue │ │ ├── User.vue │ │ ├── Monitor.vue │ │ ├── Weather.vue │ │ └── Info.vue │ ├── router.js │ ├── mixins │ │ └── apiMixin.js │ ├── main.js │ ├── services │ │ ├── graph.js │ │ └── auth.js │ └── App.vue ├── babel.config.js ├── .env.development ├── README.md ├── .gitignore ├── jest.config.js ├── .eslintrc.js └── package.json ├── server ├── .env.sample ├── cmd │ ├── .env.sample │ ├── spa.go │ └── main.go ├── .golangci.yaml ├── .air.toml ├── pkg │ ├── api │ │ ├── base.go │ │ └── middleware.go │ └── backend │ │ ├── config.go │ │ ├── weather.go │ │ ├── backend.go │ │ ├── tools.go │ │ ├── metrics.go │ │ ├── backend_test.go │ │ └── sys-info.go ├── go.mod └── go.sum ├── .dockerignore ├── deploy ├── kubernetes │ ├── secrets.sample.sh │ ├── readme.md │ ├── aks-live.yaml │ └── app.sample.yaml ├── readme.md └── container-app.bicep ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── .prettierrc ├── .github ├── scripts │ ├── release.sh │ └── url-check.sh └── workflows │ ├── publish.yaml │ ├── ci-build.yaml │ └── cd-release-aks.yaml ├── .gitignore ├── LICENSE ├── .devcontainer └── devcontainer.json ├── CONTRIBUTING.md ├── tests └── postman_collection.json ├── makefile └── README.md /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | coverage/** -------------------------------------------------------------------------------- /frontend/tests/unit/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /server/.env.sample: -------------------------------------------------------------------------------- 1 | WEATHER_API_KEY=__CHANGE_ME__ 2 | AUTH_CLIENT_ID=__CHANGE_ME__ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | .git 4 | Dockerfile 5 | 6 | server/server 7 | -------------------------------------------------------------------------------- /frontend/tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /deploy/kubernetes/secrets.sample.sh: -------------------------------------------------------------------------------- 1 | kubectl create secret generic vuego-demoapp --from-env-file=../../server/.env -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/vuego-demoapp/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/vuego-demoapp/HEAD/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/vuego-demoapp/HEAD/frontend/src/assets/error.png -------------------------------------------------------------------------------- /server/cmd/.env.sample: -------------------------------------------------------------------------------- 1 | WEATHER_API_KEY=__CHANGE_ME__ 2 | IPSTACK_API_KEY=__CHANGE_ME__ 3 | AUTH_CLIENT_ID=__CHANGE_ME__ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.semanticHighlighting.enabled": true, 3 | "go.lintTool": "golangci-lint" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/assets/container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/vuego-demoapp/HEAD/frontend/src/assets/container.png -------------------------------------------------------------------------------- /frontend/src/assets/octocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/vuego-demoapp/HEAD/frontend/src/assets/octocat.png -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | // This is only here for the tests, so jest can handle .vue files 2 | module.exports = { 3 | presets: ['@vue/cli-plugin-babel/preset'] 4 | } 5 | -------------------------------------------------------------------------------- /server/.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | revive: 3 | linters: 4 | enable: 5 | - revive 6 | - gofmt 7 | #issues: 8 | #include: 9 | # - EXC0002 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 150, 5 | "tabWidth": 2, 6 | "arrowParens": "always", 7 | "bracketSpacing": true, 8 | "useTabs": false, 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | # 2 | # !NOTE! Only used for local dev work 3 | # 4 | VUE_APP_API_ENDPOINT="http://localhost:4000/api" 5 | 6 | # 7 | # Only use when locally testing and want to enable & test Azure AD login feature 8 | # 9 | #VUE_APP_AUTH_CLIENT_ID="__CHANGE_ME__" -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "golang.go", 6 | "dbaeumer.vscode-eslint", 7 | "octref.vetur" 8 | ] 9 | } -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/Dial.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Dial.vue renders a dial 1`] = ` 4 |
22 5 |
Test Dial: 22%
6 |
7 | `; 8 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue.js Single Page App 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/Spinner.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Spinner.vue renders a spinner 1`] = ` 4 |
5 |
6 |
7 |
8 |
9 | `; 10 | -------------------------------------------------------------------------------- /frontend/tests/unit/Home.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Home from '@/components/Home.vue' 3 | 4 | describe('Home.vue', () => { 5 | it('renders home screen', async () => { 6 | const wrapper = mount(Home, {}) 7 | 8 | expect(wrapper.html()).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/tests/unit/About.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import About from '@/components/About.vue' 3 | 4 | describe('About.vue', () => { 5 | it('renders about screen', async () => { 6 | const wrapper = mount(About, {}) 7 | 8 | expect(wrapper.html()).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/tests/unit/Spinner.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Spinner from '@/components/Spinner.vue' 3 | 4 | describe('Spinner.vue', () => { 5 | it('renders a spinner', async () => { 6 | const wrapper = mount(Spinner, {}) 7 | 8 | expect(wrapper.html()).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | 23 | .npmrc -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/no-babel', 3 | setupFiles: ['jest-canvas-mock'], 4 | verbose: true, 5 | silent: true, 6 | transform: { 7 | '^.+\\.vue$': 'vue-jest' 8 | }, 9 | moduleNameMapper: { 10 | '\\.(css|less|svg)$': '/tests/unit/__mocks__/styleMock.js' 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/tests/unit/Dial.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Dial from '@/components/Dial.vue' 3 | 4 | describe('Dial.vue', () => { 5 | it('renders a dial', async () => { 6 | const wrapper = mount(Dial, { 7 | propsData: { 8 | value: 22, 9 | title: 'Test Dial' 10 | } 11 | }) 12 | 13 | expect(wrapper.html()).toMatchSnapshot() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /.github/scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VER=$1 4 | 5 | if [[ -z "$VER" ]]; then 6 | echo "Error! Supply version tag!" 7 | exit 1 8 | fi 9 | 10 | read -r -d '' NOTES << EOM 11 | \`\`\` 12 | docker pull ghcr.io/benc-uk/vuego-demoapp:$VER 13 | \`\`\` 14 | 15 | \`\`\` 16 | docker run --rm -it -p 4000:4000 ghcr.io/benc-uk/vuego-demoapp:$VER 17 | \`\`\` 18 | EOM 19 | 20 | gh release create $VER --title "Release v$VER" -n "$NOTES" 21 | -------------------------------------------------------------------------------- /server/.air.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root. 5 | root = "." 6 | tmp_dir = "./tmp" 7 | 8 | [build] 9 | # Just plain old shell command. You could use `make` as well. 10 | cmd = "go build -o tmp/main ./cmd" 11 | bin = "tmp/main" 12 | 13 | [misc] 14 | # Delete tmp directory on exit 15 | clean_on_exit = true -------------------------------------------------------------------------------- /server/pkg/api/base.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // This is intended to be wrapped & extended by a application API struct 4 | type Base struct { 5 | Healthy bool `json:"healthy"` // Flag for server is healthy 6 | Version string `json:"version"` // Version of the API 7 | Name string `json:"name"` // Name of this API or web service 8 | } 9 | 10 | type Error struct { 11 | Code int `json:"code"` 12 | Message string `json:"message"` 13 | } 14 | 15 | func (e *Error) Error() string { 16 | return e.Message 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/Error.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch App", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "${workspaceFolder}/server/cmd", 13 | "env": { 14 | "CONTENT_DIR": "${workspaceFolder}/frontend/dist/" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | **/yarn-error.log 15 | 16 | # Go stuff 17 | server/server 18 | server/tmp/** 19 | server/*.xml 20 | server/server.exe 21 | 22 | # secrets, ssssh! 23 | **/.env 24 | secrets.sh 25 | .secrets 26 | myparams.json 27 | 28 | # unit test reports, yawn! 29 | **/report.xml 30 | **/coverage/** 31 | unit-tests-frontend.xml 32 | test-report.html 33 | server_tests.txt 34 | bin/** 35 | tmp/** 36 | node_modules -------------------------------------------------------------------------------- /deploy/kubernetes/readme.md: -------------------------------------------------------------------------------- 1 | # Kubernetes 2 | 3 | Deployment into Kubernetes is simple using a [generic Helm chart for deploying web apps](https://github.com/benc-uk/helm-charts/tree/master/webapp) 4 | 5 | Make sure you have [Helm installed first](https://helm.sh/docs/intro/install/) 6 | 7 | First add the Helm repo 8 | ```bash 9 | helm repo add benc-uk https://benc-uk.github.io/helm-charts 10 | ``` 11 | 12 | Make a copy of `app.sample.yaml` to `myapp.yaml` and modify the values to suit your environment. If you're in a real hurry you can use the file as is and make no changes. 13 | ```bash 14 | helm install vuego-demoapp benc-uk/webapp --values myapp.yaml 15 | ``` -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Go & Vue.js Demoapp 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/prettier'], 7 | parserOptions: { 8 | ecmaVersion: 2020 9 | }, 10 | rules: { 11 | //'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 12 | //'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 13 | 'no-var': 'error', 14 | 'prefer-const': 'error' 15 | }, 16 | overrides: [ 17 | { 18 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'], 19 | env: { 20 | jest: true 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /deploy/kubernetes/aks-live.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # This version of the app is deployed to AKS using cd-release-aks 3 | # 4 | 5 | image: 6 | repository: ghcr.io/benc-uk/vuego-demoapp 7 | pullPolicy: Always 8 | 9 | service: 10 | targetPort: 4000 11 | 12 | secretEnv: 13 | WEATHER_API_KEY: 14 | secretName: vuego-demoapp 15 | secretKey: weatherKey 16 | AUTH_CLIENT_ID: 17 | secretName: vuego-demoapp 18 | secretKey: authClientId 19 | 20 | ingress: 21 | enabled: true 22 | className: nginx 23 | host: vuego-demoapp.kube.benco.io 24 | annotations: 25 | nginx.ingress.kubernetes.io/ssl-redirect: 'true' 26 | tls: 27 | enabled: true 28 | secretName: benco-io-cert 29 | -------------------------------------------------------------------------------- /frontend/tests/unit/App.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import App from '@/App.vue' 3 | import router from '@/router' 4 | 5 | describe('App.vue', () => { 6 | it('renders main app screen', async () => { 7 | const wrapper = mount(App, { 8 | global: { 9 | plugins: [router] 10 | } 11 | }) 12 | 13 | expect(wrapper.html()).toMatchSnapshot() 14 | }) 15 | 16 | it('renders navigates to about', async () => { 17 | const wrapper = mount(App, { 18 | global: { 19 | plugins: [router] 20 | } 21 | }) 22 | 23 | router.push('/about') 24 | await router.isReady() 25 | expect(wrapper.html()).toMatchSnapshot() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /deploy/readme.md: -------------------------------------------------------------------------------- 1 | # Deploy into Azure 2 | 3 | To deploy as a Azure Container App, a Bicep template is provided 4 | 5 | Set resource group and region vars: 6 | 7 | ```bash 8 | RES_GRP=demoapps 9 | REGION=northeurope 10 | ``` 11 | 12 | Create resource group: 13 | 14 | ```bash 15 | az group create --name $RES_GRP --location $REGION -o table 16 | ``` 17 | 18 | Deploy Azure Container App 19 | 20 | ```bash 21 | az deployment group create --template-file container-app.bicep --resource-group $RES_GRP 22 | ``` 23 | 24 | Optional deployment parameters, each one maps to an environment variable (see [main docs](../#configuration) for details): 25 | 26 | - **weatherKey** - Set to an OpenWeather API key, see main docs 27 | - **azureClientId** - Set to an Azure AD Client ID, see main docs 28 | -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/About.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`About.vue renders about screen 1`] = ` 4 |
5 |

About

6 |
7 |
Developed by Ben Coleman, 2018~2021
8 |
    9 |
  • App Version 3.0.0
  • 10 |
  • API Endpoint /api
  • 11 |
12 |
13 |
14 | `; 15 | -------------------------------------------------------------------------------- /frontend/tests/unit/Monitor.spec.js: -------------------------------------------------------------------------------- 1 | import { mount, flushPromises } from '@vue/test-utils' 2 | import Monitor from '@/components/Monitor.vue' 3 | 4 | global.fetch = jest.fn(() => 5 | Promise.resolve({ 6 | ok: true, 7 | status: 200, 8 | statusText: 'OK', 9 | headers: { 10 | get: () => 'application/json' 11 | }, 12 | json: () => 13 | Promise.resolve({ 14 | memTotal: 300, 15 | memUsed: 150, 16 | cpuPerc: 42, 17 | diskTotal: 500, 18 | diskFree: 200, 19 | netBytesSent: 300, 20 | netBytesRecv: 300 21 | }) 22 | }) 23 | ) 24 | 25 | describe('Monitor.vue', () => { 26 | it('renders monitor screen', async () => { 27 | const wrapper = mount(Monitor, {}) 28 | await flushPromises() 29 | expect(wrapper.html()).toMatchSnapshot() 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /deploy/kubernetes/app.sample.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # See this Helm chart for all options 3 | # https://github.com/benc-uk/helm-charts/tree/master/webapp 4 | # 5 | 6 | image: 7 | repository: ghcr.io/benc-uk/vuego-demoapp 8 | tag: latest 9 | pullPolicy: Always 10 | 11 | service: 12 | targetPort: 4000 13 | # Use ClusterIP if you set up ingress 14 | type: LoadBalancer 15 | # 16 | # Create these secrets if you want to enable optional features, see secrets.sample.sh 17 | # 18 | # secretEnv: 19 | # WEATHER_API_KEY: 20 | # secretName: vuego-secrets 21 | # secretKey: WEATHER_API_KEY 22 | # AUTH_CLIENT_ID: 23 | # secretName: vuego-demoapp 24 | # secretKey: AUTH_CLIENT_ID 25 | 26 | # 27 | # If you have an ingress controller set up 28 | # 29 | # ingress: 30 | # enabled: true 31 | # host: changeme.example.net 32 | # tls: 33 | # enabled: true 34 | # secretName: changeme-cert-secret 35 | -------------------------------------------------------------------------------- /frontend/tests/unit/Info.spec.js: -------------------------------------------------------------------------------- 1 | import { mount, flushPromises } from '@vue/test-utils' 2 | import Info from '@/components/Info.vue' 3 | 4 | global.fetch = jest.fn(() => 5 | Promise.resolve({ 6 | ok: true, 7 | status: 200, 8 | statusText: 'OK', 9 | headers: { 10 | get: () => 'application/json' 11 | }, 12 | json: () => 13 | Promise.resolve({ 14 | hostname: 'test', 15 | isContainer: true, 16 | isKubernetes: true, 17 | platform: 'PDP-11', 18 | architecture: '98 bit', 19 | os: 'MegaOS: 3000', 20 | mem: 1234567890, 21 | envVars: ['UNIT_TESTS=Are pointless'] 22 | }) 23 | }) 24 | ) 25 | 26 | describe('Info.vue', () => { 27 | it('renders info screen', async () => { 28 | const wrapper = mount(Info, {}) 29 | await flushPromises() 30 | expect(wrapper.html()).toMatchSnapshot() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Release Versioned Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | IMAGE_REG: ghcr.io 9 | IMAGE_REPO: benc-uk/vuego-demoapp 10 | 11 | permissions: 12 | packages: write 13 | 14 | jobs: 15 | publish-image: 16 | name: 'Build & Publish' 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 'Checkout' 20 | uses: actions/checkout@v2 21 | 22 | - name: 'Docker build image with version tag' 23 | run: | 24 | make image IMAGE_TAG=${{ github.event.release.tag_name }} 25 | make image IMAGE_TAG=latest 26 | 27 | - name: 'Push to container registry' 28 | run: | 29 | echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REG -u $GITHUB_ACTOR --password-stdin 30 | make push IMAGE_TAG=${{ github.event.release.tag_name }} 31 | make push IMAGE_TAG=latest 32 | -------------------------------------------------------------------------------- /server/pkg/backend/config.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | // Used by config endpoint 10 | type configData struct { 11 | AuthClientID string `json:"authClientId"` 12 | WeatherEnabled bool `json:"weatherEnabled"` 13 | } 14 | 15 | // Route for fetching config from the server 16 | func (a *API) getConfig(resp http.ResponseWriter, req *http.Request) { 17 | config := &configData{} 18 | 19 | config.AuthClientID = os.Getenv("AUTH_CLIENT_ID") 20 | _, weatherEnabled := os.LookupEnv("WEATHER_API_KEY") 21 | config.WeatherEnabled = weatherEnabled 22 | 23 | jsonResp, err := json.Marshal(config) 24 | if err != nil { 25 | apiError(resp, http.StatusInternalServerError, err.Error()) 26 | return 27 | } 28 | 29 | // Fire JSON result back down the internet tubes 30 | resp.Header().Set("Content-Type", "application/json") 31 | _, _ = resp.Write(jsonResp) 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Coleman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /server/pkg/backend/weather.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | // Route to proxy weather data from api.openweathermap.org 12 | func (a *API) getWeather(resp http.ResponseWriter, req *http.Request) { 13 | if a.WeatherAPIKey == "" { 14 | apiError(resp, http.StatusNotImplemented, "Feature disabled, WEATHER_API_KEY is not set") 15 | return 16 | } 17 | 18 | vars := mux.Vars(req) 19 | 20 | // Fetch fetch weather data from OpenWeather API 21 | url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%s&lon=%s&appid=%s&units=metric", vars["lat"], vars["long"], a.WeatherAPIKey) 22 | apiResp, err := http.Get(url) 23 | if err != nil { 24 | apiError(resp, http.StatusInternalServerError, err.Error()) 25 | return 26 | } 27 | if apiResp.StatusCode != 200 { 28 | apiError(resp, apiResp.StatusCode, "OpenWeather API error: "+apiResp.Status) 29 | return 30 | } 31 | // We simply proxy the result back to the client 32 | body, _ := ioutil.ReadAll(apiResp.Body) 33 | 34 | resp.Header().Set("Content-Type", "application/json") 35 | _, _ = resp.Write(body) 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/About.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | 27 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/Monitor.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Monitor.vue renders monitor screen 1`] = ` 4 |
5 |

Monitoring

6 |
7 | 8 | 9 |
10 |
11 |
12 |
42 13 |
CPU Load: 42%
14 |
15 |
16 |
17 |
50 18 |
Memory Used: 50%
19 |
20 |
21 |
22 |
23 |
24 |
60 25 |
Disk Used: 60%
26 |
27 |
28 |
29 |
0 30 |
Net I/O: 0 31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | `; 40 | -------------------------------------------------------------------------------- /frontend/src/router.js: -------------------------------------------------------------------------------- 1 | // 2 | // App router 3 | // ---------------------------------------------- 4 | // Ben C, April 2018, Updated for Vue3 2021 5 | // 6 | 7 | import Home from './components/Home.vue' 8 | import About from './components/About.vue' 9 | import Error from './components/Error.vue' 10 | import Info from './components/Info.vue' 11 | import Monitor from './components/Monitor.vue' 12 | import Weather from './components/Weather.vue' 13 | import User from './components/User.vue' 14 | import { createRouter, createWebHashHistory } from 'vue-router' 15 | 16 | const router = createRouter({ 17 | history: createWebHashHistory(), 18 | routes: [ 19 | { 20 | path: '/', 21 | name: 'home', 22 | component: Home 23 | }, 24 | { 25 | path: '/home', 26 | name: 'apphome', 27 | component: Home 28 | }, 29 | { 30 | path: '/info', 31 | name: 'info', 32 | component: Info 33 | }, 34 | { 35 | path: '/monitor', 36 | name: 'monitor', 37 | component: Monitor 38 | }, 39 | { 40 | path: '/weather', 41 | name: 'weather', 42 | component: Weather 43 | }, 44 | { 45 | path: '/about', 46 | name: 'about', 47 | component: About 48 | }, 49 | { 50 | path: '/user', 51 | name: 'user', 52 | component: User 53 | }, 54 | { 55 | path: '/:catchAll(.*)', 56 | name: 'catchall', 57 | component: Error 58 | } 59 | ] 60 | }) 61 | 62 | export default router 63 | -------------------------------------------------------------------------------- /server/pkg/backend/backend.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/benc-uk/vuego-demoapp/server/pkg/api" 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | // API is the main backend application API 13 | type API struct { 14 | WeatherAPIKey string 15 | // Use composition and embedding to extend the API base 16 | api.Base 17 | } 18 | 19 | // HTTPError holds API JSON error 20 | type HTTPError struct { 21 | Error string `json:"error"` 22 | } 23 | 24 | // 25 | // Adds backend app routes 26 | // 27 | func (a *API) AddRoutes(router *mux.Router) { 28 | router.HandleFunc("/api/info", a.getInfo).Methods("GET") 29 | router.HandleFunc("/api/monitor", a.getMonitorMetrics).Methods("GET") 30 | router.HandleFunc("/api/config", a.getConfig).Methods("GET") 31 | router.HandleFunc("/api/weather/{lat}/{long}", a.getWeather).Methods("GET") 32 | router.HandleFunc("/api/gc", a.getRunGC).Methods("GET") 33 | router.HandleFunc("/api/alloc", a.postAllocMem).Methods("POST") 34 | router.HandleFunc("/api/cpu", a.postForceCPU).Methods("POST") 35 | a.Healthy = true 36 | } 37 | 38 | // 39 | // Helper function for returning API errors 40 | // 41 | func apiError(resp http.ResponseWriter, code int, message string) { 42 | resp.WriteHeader(code) 43 | 44 | errorData := &HTTPError{ 45 | Error: message, 46 | } 47 | 48 | errorResp, err := json.Marshal(errorData) 49 | if err != nil { 50 | fmt.Printf("### ERROR! Unable to marshal to JSON. Message was %s\n", message) 51 | return 52 | } 53 | _, _ = resp.Write(errorResp) 54 | } 55 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/benc-uk/vuego-demoapp/server 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/elastic/go-sysinfo v1.7.1 7 | github.com/gorilla/handlers v1.5.1 8 | github.com/gorilla/mux v1.8.0 9 | github.com/joho/godotenv v1.4.0 10 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 11 | github.com/prometheus/client_golang v1.11.0 12 | github.com/shirou/gopsutil v3.21.10+incompatible 13 | github.com/shirou/gopsutil/v3 v3.21.10 14 | ) 15 | 16 | require ( 17 | github.com/StackExchange/wmi v1.2.1 // indirect 18 | github.com/beorn7/perks v1.0.1 // indirect 19 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 20 | github.com/elastic/go-windows v1.0.0 // indirect 21 | github.com/felixge/httpsnoop v1.0.1 // indirect 22 | github.com/go-ole/go-ole v1.2.6 // indirect 23 | github.com/golang/protobuf v1.4.3 // indirect 24 | github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect 25 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 26 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 27 | github.com/pkg/errors v0.9.1 // indirect 28 | github.com/prometheus/client_model v0.2.0 // indirect 29 | github.com/prometheus/common v0.26.0 // indirect 30 | github.com/prometheus/procfs v0.6.0 // indirect 31 | github.com/tklauser/go-sysconf v0.3.9 // indirect 32 | github.com/tklauser/numcpus v0.3.0 // indirect 33 | golang.org/x/sys v0.0.0-20211013075003-97ac67df715c // indirect 34 | google.golang.org/protobuf v1.26.0-rc.1 // indirect 35 | howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /frontend/src/components/Spinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 20 | 21 | 79 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Go + Node", 3 | "image": "mcr.microsoft.com/vscode/devcontainers/go:1.17-bullseye", 4 | 5 | // Set *default* container specific settings.json values on container create. 6 | "settings": {}, 7 | 8 | // Add the IDs of extensions you want installed when the container is created. 9 | "extensions": [ 10 | "golang.Go", 11 | "octref.vetur", 12 | "dbaeumer.vscode-eslint", 13 | "esbenp.prettier-vscode", 14 | "mikestead.dotenv", 15 | "ms-azuretools.vscode-bicep", 16 | "github.vscode-pull-request-github" 17 | ], 18 | 19 | // Optional features, uncomment to enable. 20 | // See https://code.visualstudio.com/docs/remote/containers#_dev-container-features-preview 21 | "features": { 22 | // Do not remove this feature! 23 | "node": { 24 | "version": "lts", 25 | "nodeGypDependencies": true 26 | } 27 | // "github": "latest" 28 | // "azure-cli": "latest", 29 | // "kubectl-helm-minikube": { 30 | // "version": "latest", 31 | // "helm": "latest", 32 | // "minikube": "none" 33 | // }, 34 | // "docker-from-docker": { 35 | // "version": "latest", 36 | // "moby": true 37 | // } 38 | }, 39 | 40 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 41 | //"forwardPorts": [4000, 8000], 42 | 43 | // This seems to work better with the Vue CLI hot-reload server 44 | "appPort": [4000, 8080], 45 | 46 | // Use 'postCreateCommand' to run commands after the container is created. 47 | "postCreateCommand": "curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b /go/bin", 48 | 49 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 50 | "remoteUser": "vscode" 51 | } 52 | -------------------------------------------------------------------------------- /server/cmd/spa.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // ====================================================================== 4 | // Lifted directly from 5 | // https://github.com/gorilla/mux#serving-single-page-applications 6 | // ====================================================================== 7 | 8 | import ( 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | type spaHandler struct { 15 | staticPath string 16 | indexPath string 17 | } 18 | 19 | // ServeHTTP inspects the URL path to locate a file within the static dir 20 | // on the SPA handler. If a file is found, it will be served. If not, the 21 | // file located at the index path on the SPA handler will be served. This 22 | // is suitable behavior for serving an SPA (single page application). 23 | func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 24 | // get the absolute path to prevent directory traversal 25 | path, err := filepath.Abs(r.URL.Path) 26 | if err != nil { 27 | // if we failed to get the absolute path respond with a 400 bad request 28 | // and stop 29 | http.Error(w, err.Error(), http.StatusBadRequest) 30 | return 31 | } 32 | 33 | // prepend the path with the path to the static directory 34 | path = filepath.Join(h.staticPath, path) 35 | 36 | // check whether a file exists at the given path 37 | _, err = os.Stat(path) 38 | if os.IsNotExist(err) { 39 | // file does not exist, serve index.html 40 | http.ServeFile(w, r, filepath.Join(h.staticPath, h.indexPath)) 41 | return 42 | } else if err != nil { 43 | // if we got an error (that wasn't that the file doesn't exist) stating the 44 | // file, return a 500 internal server error and stop 45 | http.Error(w, err.Error(), http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | // otherwise, use http.FileServer to serve the static dir 50 | http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r) 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/mixins/apiMixin.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | // 4 | // Mixin added to components, all API logic here 5 | // ---------------------------------------------- 6 | // Ben C, April 2018 7 | // 8 | 9 | export default { 10 | methods: { 11 | apiGetWeather: function (lat, long) { 12 | return apiCall(`/weather/${lat}/${long}`) 13 | }, 14 | 15 | apiGetMetrics: function () { 16 | return apiCall('/monitor') 17 | }, 18 | 19 | apiGetInfo: function () { 20 | return apiCall('/info') 21 | } 22 | } 23 | } 24 | 25 | // 26 | // ===== Base fetch wrapper, not exported ===== 27 | // 28 | async function apiCall(apiPath, method = 'get', data = null) { 29 | const headers = {} 30 | const url = `${process.env.VUE_APP_API_ENDPOINT || '/api'}${apiPath}` 31 | console.log(`### API CALL ${method} ${url}`) 32 | 33 | // Build request 34 | const request = { 35 | method, 36 | headers 37 | } 38 | 39 | // Add payload if required 40 | if (data) { 41 | request.body = JSON.stringify(data) 42 | } 43 | 44 | // Make the HTTP request 45 | const resp = await fetch(url, request) 46 | 47 | // Decode error message when non-HTTP OK (200~299) & JSON is received 48 | if (!resp.ok) { 49 | let error = `API call to ${url} failed with ${resp.status} ${resp.statusText}` 50 | if (resp.headers && resp.headers.get('Content-Type') === 'application/json') { 51 | error = `Status: ${resp.statusText}\n` 52 | const errorObj = await resp.json() 53 | for (const [key, value] of Object.entries(errorObj)) { 54 | error += `${key}: '${value}\n', ` 55 | } 56 | } 57 | throw new Error(error) 58 | } 59 | 60 | // Attempt to return response body as data object if JSON 61 | if (resp.headers && resp.headers.get('Content-Type') === 'application/json') { 62 | return resp.json() 63 | } else { 64 | return resp.text() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/Home.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Home.vue renders home screen 1`] = ` 4 |
5 |
6 |

Go & Vue.js Demo

7 |

This is a standard modern web application with a Go RESTful backend and a Vue.js single page application frontend. It has been designed with cloud demos & containers in mind. Demonstrating capabilities such as deployment to Azure or Kubernetes, auto scaling, or anytime you want something quick and lightweight to run & deploy.

Basic features:
    8 |
  • System status / information view
  • 9 |
  • Geolocated weather info (from OpenWeather API)
  • 10 |
  • Realtime monitoring and metrics
  • 11 |
  • Support for user authentication with Azure AD
  • 12 |
13 | 18 |
🚀 Demo Apps Collection — For more demo apps in different languages & frameworks
19 |
20 |
21 | `; 22 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuego-demoapp-spa", 3 | "version": "3.0.0", 4 | "private": true, 5 | "description": "Frontend web client SPA for vuego-demoapp", 6 | "author": { 7 | "name": "Ben Coleman" 8 | }, 9 | "repository": { 10 | "url": "https://github.com/benc-uk/vuego-demoapp" 11 | }, 12 | "license": "MIT", 13 | "scripts": { 14 | "serve": "vue-cli-service serve", 15 | "build": "vue-cli-service build", 16 | "lint-fix": "vue-cli-service lint && prettier --write '**/*.{js,vue}'", 17 | "lint": "vue-cli-service lint --no-fix && prettier --check '**/*.{js,vue}'", 18 | "test": "vue-cli-service test:unit", 19 | "test-report": "vue-cli-service test:unit --reporters='default' --reporters='./node_modules/jest-html-reporter'", 20 | "test-update": "vue-cli-service test:unit --updateSnapshot" 21 | }, 22 | "dependencies": { 23 | "@azure/msal-browser": "^2.13.0", 24 | "@fortawesome/fontawesome-free": "^5.15.4", 25 | "bootstrap": "^5.1.3", 26 | "bootswatch": "^5.1.3", 27 | "gaugeJS": "^1.3.7", 28 | "open-weather-icons": "^0.0.8", 29 | "vue": "^3.2.0", 30 | "vue-router": "^4.0.12" 31 | }, 32 | "devDependencies": { 33 | "@vue/cli-plugin-babel": "^4.5.15", 34 | "@vue/cli-plugin-eslint": "^4.5.15", 35 | "@vue/cli-plugin-unit-jest": "^4.5.15", 36 | "@vue/cli-service": "^4.5.12", 37 | "@vue/compiler-sfc": "^3.0.0", 38 | "@vue/eslint-config-prettier": "^6.0.0", 39 | "@vue/test-utils": "^2.0.0-0", 40 | "eslint": "^6.7.2", 41 | "eslint-plugin-prettier": "^3.3.1", 42 | "eslint-plugin-vue": "^7.0.0", 43 | "jest-canvas-mock": "^2.3.1", 44 | "jest-html-reporter": "^3.3.0", 45 | "newman": "^5.2.2", 46 | "prettier": "^2.2.1", 47 | "vue-jest": "^5.0.0-0", 48 | "babel-jest": "^26.0.0" 49 | }, 50 | "postcss": { 51 | "plugins": { 52 | "autoprefixer": {} 53 | } 54 | }, 55 | "browserslist": [ 56 | "> 1%", 57 | "last 2 versions", 58 | "not dead" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | // 2 | // Main starting point for Vue.js SPA 3 | // ---------------------------------------------- 4 | // Ben C, April 2018, Updated for Vue3 2021 5 | // 6 | 7 | import { createApp } from 'vue' 8 | import App from './App.vue' 9 | import router from './router' 10 | import auth from './services/auth' 11 | 12 | // Bootstrap and icons 13 | import 'bootswatch/dist/vapor/bootstrap.min.css' 14 | import 'bootstrap/dist/js/bootstrap.bundle' 15 | import '@fortawesome/fontawesome-free/js/all' 16 | // Weather Icons 17 | import 'open-weather-icons/dist/css/open-weather-icons.css' 18 | import 'open-weather-icons/dist/fonts/OpenWeatherIcons.svg' 19 | 20 | const app = createApp(App) 21 | app.use(router) 22 | 23 | // Let's go! 24 | startup(app) 25 | 26 | // Default config 27 | let config = { 28 | AUTH_CLIENT_ID: null, 29 | WEATHER_ENABLED: false 30 | } 31 | 32 | // 33 | // App start up synchronized using await with the config API call 34 | // 35 | async function startup(app) { 36 | // Take Azure AD client-id from .env.development or .env.development.local if it's set 37 | // Fall back to empty string which disables the auth feature 38 | let AUTH_CLIENT_ID = process.env.VUE_APP_AUTH_CLIENT_ID || '' 39 | 40 | // Load config at runtime from special `/config` endpoint on Go server backend 41 | const apiEndpoint = process.env.VUE_APP_API_ENDPOINT || '/api' 42 | try { 43 | const configResp = await fetch(`${apiEndpoint}/config`) 44 | if (configResp.ok) { 45 | config = await configResp.json() 46 | AUTH_CLIENT_ID = config.authClientId 47 | console.log('### Config loaded from server API:', config) 48 | } 49 | } catch (err) { 50 | console.warn(`### Failed to fetch remote '${apiEndpoint}' endpoint. Local value for AUTH_CLIENT_ID '${AUTH_CLIENT_ID}' will be used`) 51 | } 52 | 53 | // Setup auth helper but disable dummy user 54 | // if AUTH_CLIENT_ID isn't set at this point, then the user sign-in will be dynamically disabled 55 | auth.configure(AUTH_CLIENT_ID, false) 56 | 57 | app.mount('#app') 58 | } 59 | 60 | export { config } 61 | -------------------------------------------------------------------------------- /server/pkg/backend/tools.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "runtime" 7 | "time" 8 | ) 9 | 10 | // Force garbage collection 11 | func (a *API) getRunGC(resp http.ResponseWriter, req *http.Request) { 12 | runtime.GC() 13 | 14 | resp.Header().Set("Content-Type", "application/json") 15 | _, _ = resp.Write([]byte("Garbage collector was run")) 16 | } 17 | 18 | // Allocate a lot of memory 19 | func (a *API) postAllocMem(resp http.ResponseWriter, req *http.Request) { 20 | params := struct { 21 | Size int 22 | }{ 23 | Size: 100, 24 | } 25 | 26 | err := json.NewDecoder(req.Body).Decode(¶ms) 27 | if err != nil { 28 | http.Error(resp, err.Error(), http.StatusBadRequest) 29 | return 30 | } 31 | 32 | if params.Size < 1 { 33 | params.Size = 1 34 | } 35 | if params.Size > 400 { 36 | params.Size = 400 37 | } 38 | 39 | var buffer = make([]string, params.Size*1024*1024) 40 | for e := range buffer { 41 | buffer[e] = "#" 42 | } 43 | 44 | resp.Header().Set("Content-Type", "application/json") 45 | _, _ = resp.Write([]byte("Memory was allocated")) 46 | } 47 | 48 | // Force max CPU load for a number of seconds 49 | func (a *API) postForceCPU(resp http.ResponseWriter, req *http.Request) { 50 | params := struct { 51 | Seconds int 52 | }{ 53 | Seconds: 1, 54 | } 55 | 56 | err := json.NewDecoder(req.Body).Decode(¶ms) 57 | if err != nil { 58 | http.Error(resp, err.Error(), http.StatusBadRequest) 59 | return 60 | } 61 | if params.Seconds < 1 { 62 | params.Seconds = 1 63 | } 64 | if params.Seconds > 10 { 65 | params.Seconds = 10 66 | } 67 | 68 | n := runtime.NumCPU() 69 | runtime.GOMAXPROCS(n) 70 | 71 | quit := make(chan bool) 72 | 73 | for i := 0; i < n; i++ { 74 | go func() { 75 | for { 76 | select { 77 | case <-quit: 78 | return 79 | default: //nolint 80 | } 81 | } 82 | }() 83 | } 84 | 85 | time.Sleep(time.Duration(params.Seconds) * time.Second) 86 | for i := 0; i < n; i++ { 87 | quit <- true 88 | } 89 | 90 | resp.Header().Set("Content-Type", "application/json") 91 | _, _ = resp.Write([]byte("CPU load was forced")) 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yaml: -------------------------------------------------------------------------------- 1 | name: CI Build App 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - 'server/**' 8 | - 'spa/**' 9 | - '.github/workflows/**' 10 | pull_request: 11 | 12 | env: 13 | IMAGE_REG: ghcr.io 14 | IMAGE_REPO: benc-uk/vuego-demoapp 15 | 16 | permissions: 17 | packages: write 18 | 19 | jobs: 20 | test: 21 | name: 'Tests & Linting' 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: 'Checkout' 25 | uses: actions/checkout@v2 26 | 27 | - name: 'Run linting' 28 | run: make lint 29 | 30 | - name: 'Run tests with report' 31 | run: make test-report 32 | 33 | - name: 'Upload test results' 34 | uses: actions/upload-artifact@v2 35 | # Disabled when running locally with the nektos/act tool 36 | if: ${{ always() && !env.ACT }} 37 | with: 38 | name: test-results 39 | path: | 40 | server/test-report.html 41 | frontend/test-report.html 42 | 43 | build: 44 | name: 'Build & Push Image' 45 | needs: test 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: 'Checkout' 49 | uses: actions/checkout@v2 50 | 51 | # Nicer than using github runid, I think, will be picked up automatically by make 52 | - name: 'Create datestamp image tag' 53 | run: echo "IMAGE_TAG=$(date +%d-%m-%Y.%H%M)" >> $GITHUB_ENV 54 | 55 | - name: 'Docker build image' 56 | run: make image 57 | 58 | # Only when pushing to default branch (e.g. master or main), then push image to registry 59 | - name: 'Push to container registry' 60 | if: github.ref == 'refs/heads/master' && github.event_name == 'push' 61 | run: | 62 | echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REG -u $GITHUB_ACTOR --password-stdin 63 | make push 64 | 65 | - name: 'Trigger AKS release pipeline' 66 | if: github.ref == 'refs/heads/master' 67 | uses: benc-uk/workflow-dispatch@v1 68 | with: 69 | workflow: 'CD Release - AKS' 70 | token: ${{ secrets.GH_PAT }} 71 | inputs: '{ "IMAGE_TAG": "${{ env.IMAGE_TAG }}" }' 72 | -------------------------------------------------------------------------------- /frontend/src/assets/go.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/pkg/backend/metrics.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/shirou/gopsutil/cpu" 8 | "github.com/shirou/gopsutil/disk" 9 | "github.com/shirou/gopsutil/mem" 10 | "github.com/shirou/gopsutil/net" 11 | ) 12 | 13 | // Metrics are real time system counters 14 | type Metrics struct { 15 | MemTotal uint64 `json:"memTotal"` 16 | MemUsed uint64 `json:"memUsed"` 17 | CPUPerc float64 `json:"cpuPerc"` 18 | DiskTotal uint64 `json:"diskTotal"` 19 | DiskFree uint64 `json:"diskFree"` 20 | NetBytesSent uint64 `json:"netBytesSent"` 21 | NetBytesRecv uint64 `json:"netBytesRecv"` 22 | } 23 | 24 | // Route to return system metrics cpu, mem, etc 25 | // NOTE! This is not the same as /api/metrics used for Prometheus, registered with AddMetrics 26 | func (a *API) getMonitorMetrics(resp http.ResponseWriter, req *http.Request) { 27 | var metrics Metrics 28 | 29 | // Memory stuff 30 | memStats, err := mem.VirtualMemory() 31 | if err != nil { 32 | apiError(resp, http.StatusInternalServerError, "Virtual memory "+err.Error()) 33 | return 34 | } 35 | metrics.MemTotal = memStats.Total 36 | metrics.MemUsed = memStats.Used 37 | 38 | // CPU / processor stuff 39 | cpuStats, err := cpu.Percent(0, false) 40 | if err != nil { 41 | apiError(resp, http.StatusInternalServerError, "CPU percentage "+err.Error()) 42 | return 43 | } 44 | metrics.CPUPerc = cpuStats[0] 45 | 46 | // Disk and filesystem usage stuff 47 | diskStats, err := disk.Usage("/") 48 | if err != nil { 49 | apiError(resp, http.StatusInternalServerError, "Disk usage "+err.Error()) 50 | return 51 | } 52 | metrics.DiskTotal = diskStats.Total 53 | metrics.DiskFree = diskStats.Free 54 | 55 | // Network stuff 56 | netStats, err := net.IOCounters(false) 57 | if err != nil { 58 | apiError(resp, http.StatusInternalServerError, "IOCounters "+err.Error()) 59 | return 60 | } 61 | metrics.NetBytesRecv = netStats[0].BytesRecv 62 | metrics.NetBytesSent = netStats[0].BytesSent 63 | 64 | // JSON-ify our metrics 65 | js, err := json.Marshal(metrics) 66 | if err != nil { 67 | apiError(resp, http.StatusInternalServerError, err.Error()) 68 | return 69 | } 70 | 71 | // Fire JSON result back down the internet tubes 72 | resp.Header().Set("Content-Type", "application/json") 73 | _, _ = resp.Write(js) 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/cd-release-aks.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Deploy to Azure Kubernetes Service 3 | # Using Helm for parameterized deployment 4 | # 5 | 6 | name: CD Release - AKS 7 | 8 | on: 9 | workflow_dispatch: 10 | inputs: 11 | IMAGE_TAG: 12 | description: 'Image tag to be deployed' 13 | required: true 14 | default: 'latest' 15 | 16 | # Note. Required secrets: CLUSTER_KUBECONFIG 17 | 18 | env: 19 | CLUSTER_NAME: benc 20 | HELM_RELEASE: vuego 21 | HELM_NAMESPACE: demoapps 22 | INGRESS_DNS_HOST: vuego-demoapp.kube.benco.io 23 | 24 | jobs: 25 | # 26 | # Deploy to Kubernetes (AKS) 27 | # 28 | deploy-aks: 29 | name: Deploy to AKS with Helm 30 | runs-on: ubuntu-latest 31 | environment: 32 | name: AKS - vuego-demoapp 33 | url: https://${{ env.INGRESS_DNS_HOST }}/ 34 | 35 | steps: 36 | - name: 'Checkout' 37 | uses: actions/checkout@v2 38 | 39 | - name: 'Set kubeconfig' 40 | uses: azure/k8s-set-context@v1 41 | with: 42 | method: kubeconfig 43 | kubeconfig: ${{ secrets.CLUSTER_KUBECONFIG }} 44 | context: ${{ env.CLUSTER_NAME }} 45 | 46 | - name: 'Helm release' 47 | run: | 48 | helm repo add benc-uk https://benc-uk.github.io/helm-charts 49 | helm upgrade ${{ env.HELM_RELEASE }} benc-uk/webapp \ 50 | --install \ 51 | --namespace ${{ env.HELM_NAMESPACE }} \ 52 | --values deploy/kubernetes/aks-live.yaml \ 53 | --set image.tag=${{ github.event.inputs.IMAGE_TAG }},ingress.host=${{ env.INGRESS_DNS_HOST }},env.AAD_REDIRECT_URL_BASE=https://${{ env.INGRESS_DNS_HOST }} 54 | 55 | # 56 | # Post deployment testing stage 57 | # 58 | validate-deployment: 59 | name: 'Run Deployment Tests' 60 | needs: deploy-aks 61 | runs-on: ubuntu-latest 62 | environment: 63 | name: AKS - vuego-demoapp 64 | url: https://${{ env.INGRESS_DNS_HOST }}/ 65 | 66 | steps: 67 | - name: 'Checkout' 68 | uses: actions/checkout@v2 69 | 70 | - name: 'Validate site is running' 71 | run: .github/scripts/url-check.sh -u https://${{ env.INGRESS_DNS_HOST }} -s "Vue.js" -t 200 72 | 73 | - name: 'Run API tests' 74 | run: | 75 | npm install newman --silent 76 | node_modules/newman/bin/newman.js run tests/postman_collection.json --global-var BASE_URL=https://${{ env.INGRESS_DNS_HOST }} 77 | -------------------------------------------------------------------------------- /frontend/src/services/graph.js: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Copyright (c) Ben Coleman, 2020 3 | // Licensed under the MIT License. 4 | // 5 | // Set of methods to call the beta Microsoft Graph API, using REST and fetch 6 | // Requires auth.js 7 | // ---------------------------------------------------------------------------- 8 | 9 | import auth from './auth' 10 | 11 | const GRAPH_BASE = 'https://graph.microsoft.com/beta' 12 | const GRAPH_SCOPES = ['user.read', 'user.readbasic.all'] 13 | 14 | let accessToken 15 | 16 | export default { 17 | // 18 | // Get details of user, and return as JSON 19 | // https://docs.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0&tabs=http#response-1 20 | // 21 | async getSelf() { 22 | const resp = await callGraph('/me') 23 | if (resp) { 24 | const data = await resp.json() 25 | return data 26 | } 27 | }, 28 | 29 | // 30 | // Get user's photo and return as a blob object URL 31 | // https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL 32 | // 33 | async getPhoto() { 34 | const resp = await callGraph('/me/photos/648x648/$value') 35 | if (resp) { 36 | const blob = await resp.blob() 37 | return URL.createObjectURL(blob) 38 | } 39 | }, 40 | 41 | // 42 | // Search for users 43 | // https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL 44 | // 45 | async searchUsers(searchString, max = 50) { 46 | const resp = await callGraph( 47 | `/users?$filter=startswith(displayName, '${searchString}') or startswith(userPrincipalName, '${searchString}')&$top=${max}` 48 | ) 49 | if (resp) { 50 | const data = await resp.json() 51 | return data 52 | } 53 | }, 54 | 55 | // 56 | // Accessor for access token, only included for demo purposes 57 | // 58 | getAccessToken() { 59 | return accessToken 60 | } 61 | } 62 | 63 | // 64 | // Common fetch wrapper (private) 65 | // 66 | async function callGraph(apiPath) { 67 | // Acquire an access token to call APIs (like Graph) 68 | // Safe to call repeatedly as MSAL caches tokens locally 69 | accessToken = await auth.acquireToken(GRAPH_SCOPES) 70 | 71 | const resp = await fetch(`${GRAPH_BASE}${apiPath}`, { 72 | headers: { authorization: `bearer ${accessToken}` } 73 | }) 74 | 75 | if (!resp.ok) { 76 | throw new Error(`Call to ${GRAPH_BASE}${apiPath} failed: ${resp.statusText}`) 77 | } 78 | 79 | return resp 80 | } 81 | -------------------------------------------------------------------------------- /server/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/benc-uk/vuego-demoapp/server/pkg/api" 11 | "github.com/benc-uk/vuego-demoapp/server/pkg/backend" 12 | "github.com/gorilla/mux" 13 | "github.com/joho/godotenv" 14 | ) 15 | 16 | func main() { 17 | _ = godotenv.Load() 18 | 19 | fmt.Println("### 🚀 Go server backend and REST API is starting...") 20 | 21 | // Get server PORT setting or use 4000 as default 22 | serverPort := "4000" 23 | if envValue, envSet := os.LookupEnv("PORT"); envSet { 24 | serverPort = envValue 25 | } 26 | 27 | // Get CONTENT_DIR setting for static content or default 28 | contentDir := "." 29 | if envValue, envSet := os.LookupEnv("CONTENT_DIR"); envSet { 30 | contentDir = envValue 31 | } 32 | 33 | // Enable optional weather feature 34 | weatherAPIKey := "" 35 | if envValue, envSet := os.LookupEnv("WEATHER_API_KEY"); envSet { 36 | fmt.Println("### 🌞 Weather API feature enabled") 37 | weatherAPIKey = envValue 38 | } 39 | 40 | if len(os.Getenv("AUTH_CLIENT_ID")) > 0 { 41 | fmt.Printf("### 🔐 Azure AD configured with client id: %s\n", os.Getenv("AUTH_CLIENT_ID")) 42 | } 43 | 44 | // Routing using mux 45 | router := mux.NewRouter() 46 | 47 | backendAPI := &backend.API{ 48 | WeatherAPIKey: weatherAPIKey, 49 | Base: api.Base{ 50 | Healthy: true, 51 | Version: "3.0.0", 52 | Name: "VueGo-DemoApp-API", 53 | }, 54 | } 55 | 56 | // Bind application routes to the router 57 | backendAPI.AddRoutes(router) 58 | 59 | // Add logging, CORS, health & metrics middleware 60 | backendAPI.AddLogging(router) 61 | backendAPI.AddCORS([]string{"*"}, router) 62 | backendAPI.AddMetrics(router, "/api") 63 | backendAPI.AddHealth(router, "/api") 64 | backendAPI.AddStatus(router, "/api") 65 | 66 | // Add static SPA hosting 67 | spa := spaHandler{staticPath: contentDir, indexPath: "index.html"} 68 | router.PathPrefix("/").Handler(spa) 69 | 70 | server := &http.Server{ 71 | ReadTimeout: 12 * time.Second, 72 | WriteTimeout: 12 * time.Second, 73 | IdleTimeout: 30 * time.Second, 74 | ReadHeaderTimeout: 2 * time.Second, 75 | Handler: router, 76 | Addr: ":" + serverPort, 77 | } 78 | 79 | // Start server 80 | fmt.Printf("### 🌐 HTTP server listening on %v\n", serverPort) 81 | fmt.Printf("### 📁 Serving static content from '%v'\n", contentDir) 82 | err := server.ListenAndServe() 83 | if err != nil { 84 | log.Fatal(err) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/Info.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Info.vue renders info screen 1`] = ` 4 |
5 | 6 | 7 |
8 |

System Information

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |
ContainerizedRunning in a container! 😁
KubernetesRunning in Kubernetes! 😄
Hostnametest
PlatformPDP-11 98 bit
Operating SystemMegaOS: 3000
Processors x
Memory1.15 GB
Go Version
Network Address
50 |
51 |

52 |
53 |

Environment Variables

54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
UNIT_TESTSAre pointless
63 |
64 |
65 |
66 | `; 67 | -------------------------------------------------------------------------------- /server/pkg/backend/backend_test.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/benc-uk/vuego-demoapp/server/pkg/api" 10 | ) 11 | 12 | var backendAPI *API 13 | 14 | func init() { 15 | backendAPI = &API{ 16 | WeatherAPIKey: "", 17 | Base: api.Base{ 18 | Healthy: true, 19 | Version: "3.0.0", 20 | Name: "VueGo-DemoApp-API", 21 | }, 22 | } 23 | } 24 | 25 | // 26 | // Test TestApiInfoRoute 27 | // 28 | func TestApiInfoRoute(t *testing.T) { 29 | req, err := http.NewRequest("GET", "/api/info", nil) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | rr := httptest.NewRecorder() 34 | handler := http.HandlerFunc(backendAPI.getInfo) 35 | handler.ServeHTTP(rr, req) 36 | 37 | // Check resp code 38 | if status := rr.Code; status != http.StatusOK { 39 | t.Errorf("handler returned wrong status code: got %v want %v", 40 | status, http.StatusOK) 41 | } 42 | 43 | // Check the response body is what we expect. 44 | expected := "hostname" 45 | if !strings.Contains(rr.Body.String(), expected) { 46 | t.Errorf("TestApiInfoRoute returned unexpected body: got %v want %v", 47 | rr.Body.String(), expected) 48 | } 49 | } 50 | 51 | // 52 | // Test TestWeatherRoute 53 | // 54 | func TestWeatherRoute(t *testing.T) { 55 | 56 | req, err := http.NewRequest("GET", "/api/weather", nil) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | rr := httptest.NewRecorder() 61 | handler := http.HandlerFunc(backendAPI.getWeather) 62 | handler.ServeHTTP(rr, req) 63 | 64 | // Check resp code - it will fail! 65 | if status := rr.Code; status != http.StatusNotImplemented { 66 | t.Errorf("TestWeatherRoute returned wrong status code: got %v want %v", 67 | status, http.StatusNotImplemented) 68 | } 69 | } 70 | 71 | // 72 | // Test TestConfigRoute 73 | // 74 | func TestConfigRoute(t *testing.T) { 75 | req, err := http.NewRequest("GET", "/api/config", nil) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | rr := httptest.NewRecorder() 80 | handler := http.HandlerFunc(backendAPI.getConfig) 81 | handler.ServeHTTP(rr, req) 82 | 83 | // Check resp code - it will fail! 84 | if status := rr.Code; status != http.StatusOK { 85 | t.Errorf("TestWeatherRoute returned wrong status code: got %v want %v", 86 | status, http.StatusOK) 87 | } 88 | 89 | // Check the response body is what we expect. 90 | expected := "authClientId" 91 | if !strings.Contains(rr.Body.String(), expected) { 92 | t.Errorf("TestApiInfoRoute returned unexpected body: got %v want %v", 93 | rr.Body.String(), expected) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /frontend/src/components/Home.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 51 | 52 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/Dial.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 92 | 93 | 111 | -------------------------------------------------------------------------------- /server/pkg/api/middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/elastic/go-sysinfo" 10 | 11 | "github.com/gorilla/handlers" 12 | "github.com/gorilla/mux" 13 | "github.com/prometheus/client_golang/prometheus" 14 | "github.com/prometheus/client_golang/prometheus/promauto" 15 | "github.com/prometheus/client_golang/prometheus/promhttp" 16 | ) 17 | 18 | type Status struct { 19 | Hostname string `json:"hostname"` 20 | Uptime string `json:"uptime"` 21 | GoVersion string `json:"goVersion"` 22 | *Base 23 | } 24 | 25 | // Add logging middleware to the router in Apache Common Log Format. 26 | func (b *Base) AddLogging(r *mux.Router) { 27 | r.Use(func(next http.Handler) http.Handler { 28 | return handlers.LoggingHandler(os.Stdout, next) 29 | }) 30 | } 31 | 32 | // Add CORS middleware to the router 33 | func (b *Base) AddCORS(origins []string, r *mux.Router) { 34 | r.Use(handlers.CORS(handlers.AllowedOrigins(origins))) 35 | } 36 | 37 | // AddMetrics adds Prometheus metrics to the router 38 | func (b *Base) AddMetrics(r *mux.Router, prefix string) { 39 | r.Handle(prefix+"/metrics", promhttp.Handler()) 40 | 41 | durationHistogram := promauto.NewHistogramVec(prometheus.HistogramOpts{ 42 | Name: "response_duration_seconds", 43 | Help: "A histogram of request latencies.", 44 | Buckets: []float64{.001, .01, .1, .2, .5, 1, 2, 5}, 45 | ConstLabels: prometheus.Labels{"handler": b.Name}, 46 | }, []string{"method"}) 47 | 48 | r.Use(func(next http.Handler) http.Handler { 49 | return promhttp.InstrumentHandlerDuration(durationHistogram, next) 50 | }) 51 | } 52 | 53 | // AddHealth adds a health check endpoint to the API 54 | func (b *Base) AddHealth(r *mux.Router, prefix string) { 55 | // Add health check endpoint 56 | r.HandleFunc(prefix+"/health", func(w http.ResponseWriter, r *http.Request) { 57 | if b.Healthy { 58 | w.WriteHeader(http.StatusOK) 59 | _, _ = w.Write([]byte("OK")) 60 | } else { 61 | w.WriteHeader(http.StatusInternalServerError) 62 | _, _ = w.Write([]byte("Service is not healthy")) 63 | } 64 | }) 65 | } 66 | 67 | // AddStatus adds a status & info endpoint to the API 68 | func (b *Base) AddStatus(r *mux.Router, prefix string) { 69 | r.HandleFunc(prefix+"/status", func(w http.ResponseWriter, r *http.Request) { 70 | host, _ := sysinfo.Host() 71 | host.Info().Uptime() 72 | status := Status{ 73 | Hostname: host.Info().Hostname, 74 | Uptime: host.Info().Uptime().String(), 75 | GoVersion: runtime.Version(), 76 | Base: b, 77 | } 78 | w.Header().Set("Content-Type", "application/json") 79 | w.WriteHeader(http.StatusOK) 80 | _ = json.NewEncoder(w).Encode(status) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /deploy/container-app.bicep: -------------------------------------------------------------------------------- 1 | // ============================================================================ 2 | // Deploy a container app with app container environment and log analytics 3 | // ============================================================================ 4 | 5 | @description('Name of container app') 6 | param appName string = 'vuego-demoapp' 7 | 8 | @description('Region to deploy into') 9 | param location string = resourceGroup().location 10 | 11 | @description('Container image to deploy') 12 | param image string = 'ghcr.io/benc-uk/vuego-demoapp:latest' 13 | 14 | @description('Optional feature: OpenWeather API Key') 15 | param weatherApiKey string = '' 16 | 17 | @description('Optional feature: Azure AD Client ID') 18 | param authClientId string = '' 19 | 20 | // ===== Variables ============================================================ 21 | 22 | var logWorkspaceName = '${resourceGroup().name}-logs' 23 | var environmentName = '${resourceGroup().name}-environment' 24 | 25 | // ===== Modules & Resources ================================================== 26 | 27 | resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' = { 28 | location: location 29 | name: logWorkspaceName 30 | properties:{ 31 | sku:{ 32 | name: 'Free' 33 | } 34 | } 35 | } 36 | 37 | resource kubeEnv 'Microsoft.Web/kubeEnvironments@2021-02-01' = { 38 | location: location 39 | name: environmentName 40 | kind: 'containerenvironment' 41 | 42 | properties: { 43 | type: 'Managed' 44 | appLogsConfiguration: { 45 | destination: 'log-analytics' 46 | logAnalyticsConfiguration: { 47 | customerId: logWorkspace.properties.customerId 48 | sharedKey: logWorkspace.listKeys().primarySharedKey 49 | } 50 | } 51 | } 52 | } 53 | 54 | resource containerApp 'Microsoft.Web/containerApps@2021-03-01' = { 55 | location: location 56 | name: appName 57 | 58 | properties: { 59 | kubeEnvironmentId: kubeEnv.id 60 | template: { 61 | containers: [ 62 | { 63 | image: image 64 | name: appName 65 | resources: { 66 | cpu: json('0.25') 67 | memory: '0.5Gi' 68 | } 69 | env: [ 70 | { 71 | name: 'WEATHER_API_KEY' 72 | value: weatherApiKey 73 | } 74 | { 75 | name: 'AUTH_CLIENT_ID' 76 | value: authClientId 77 | } 78 | ] 79 | } 80 | ] 81 | } 82 | 83 | configuration: { 84 | ingress: { 85 | external: true 86 | targetPort: 4000 87 | } 88 | } 89 | } 90 | } 91 | 92 | // ===== Outputs ============================================================== 93 | 94 | output appURL string = 'https://${containerApp.properties.configuration.ingress.fqdn}' 95 | -------------------------------------------------------------------------------- /frontend/src/components/User.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 37 | 38 | 70 | 71 | 82 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome 2 | 3 | Hello! Thanks for taking an interest in this project and code :) 4 | 5 | Contributions to this project are welcome of course, otherwise it wouldn't reside on GitHub 😃 however there's a few things to be aware of: 6 | 7 | - This is a personal project, it is not maintained by a team or group. 8 | - It might take a long time for the maintainer(s) to reply to issues or review PRs, they will have have a day jobs & might not have looked at the code for a while. 9 | - The code here is likely to not be bullet proof & production grade, there might be a lack of unit tests or other practices missing from the code base. 10 | 11 | # Contributing 12 | 13 | There's several ways of contributing to this project, and effort has been made to make this as easy and transparent as possible, whether it's: 14 | 15 | - Reporting a bug 16 | - Discussing the current state of the code 17 | - Submitting a fix 18 | - Proposing new features 19 | - Becoming a maintainer 20 | 21 | ## All code changes happen though pull requests (PRs) 22 | 23 | Pull requests are the best way to propose changes to the codebase (using the standard [Github Flow](https://guides.github.com/introduction/flow/index.html)). 24 | 25 | Some PR guidance: 26 | 27 | - Please keep PRs small and focused on a single feature or change, with discreet commits. Use multiple PRs if need be. 28 | - If you're thinking of adding a feature via a PR please create an issue first where it can be discussed. 29 | 30 | High level steps: 31 | 32 | 1. Fork the repo and create your branch from `master` or `main`. 33 | 2. If you've changed APIs, update the documentation. 34 | 3. Ensure the test suite (if any) passes (run `make lint`). 35 | 4. Make sure your code lints (run `make lint`). 36 | 5. Issue that pull request! 37 | 38 | ## Any contributions you make will be under the MIT Software License 39 | 40 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. 41 | 42 | ## Report bugs using Github's issues 43 | 44 | This project uses GitHub issues to track public bugs. Report a bug by [opening a new issue](./issues/new/choose) 45 | 46 | ## Write bug reports with detail, background, and sample code 47 | 48 | **Great Bug Reports** tend to have: 49 | 50 | - A quick summary and/or background 51 | - Steps to reproduce 52 | - Be specific! 53 | - Give sample code if you can. Even if it's a snippet 54 | - What you expected would happen 55 | - What actually happens 56 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 57 | 58 | ## Use a consistent coding style 59 | 60 | Run `make lint-fix` in order to format the code fix any formatting & linting issues that might be present. A [Prettier](https://prettier.io/) configuration file is included 61 | 62 | # References 63 | 64 | This document was heavily adapted from the open-source contribution guidelines found in [this gist](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62) 65 | -------------------------------------------------------------------------------- /frontend/src/components/Monitor.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 45 | 46 | 129 | -------------------------------------------------------------------------------- /frontend/tests/unit/__snapshots__/App.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App.vue renders main app screen 1`] = ` 4 |
5 | 19 |
20 | 21 | 22 | 23 |
24 |
25 | `; 26 | 27 | exports[`App.vue renders navigates to about 1`] = ` 28 |
29 | 43 |
44 | 45 |
46 |

About

47 |
48 |
Developed by Ben Coleman, 2018~2021
49 |
    50 |
  • App Version 3.0.0
  • 51 |
  • API Endpoint /api
  • 52 |
53 |
54 |
55 |
56 |
57 |
58 | `; 59 | -------------------------------------------------------------------------------- /server/pkg/backend/sys-info.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/pbnjay/memory" 11 | "github.com/shirou/gopsutil/v3/cpu" 12 | "github.com/shirou/gopsutil/v3/host" 13 | ) 14 | 15 | // SysInfo is generic holder for passsing data back 16 | type SysInfo struct { 17 | Hostname string `json:"hostname"` 18 | Platform string `json:"platform"` 19 | OS string `json:"os"` 20 | Uptime uint64 `json:"uptime"` 21 | Arch string `json:"architecture"` 22 | CPUs int `json:"cpuCount"` 23 | CPUModel string `json:"cpuModel"` 24 | Mem uint64 `json:"mem"` 25 | GoVersion string `json:"goVersion"` 26 | NetRemoteAddr string `json:"netRemoteAddress"` 27 | NetHost string `json:"netHost"` 28 | IsContainer bool `json:"isContainer"` 29 | IsKubernetes bool `json:"isKubernetes"` 30 | EnvVars []string `json:"envVars"` 31 | } 32 | 33 | // Route to return system info 34 | func (a *API) getInfo(resp http.ResponseWriter, req *http.Request) { 35 | var info SysInfo 36 | 37 | hostInfo, err := host.Info() 38 | if err != nil { 39 | apiError(resp, http.StatusInternalServerError, err.Error()) 40 | return 41 | } 42 | cpuInfo, err := cpu.Info() 43 | if err != nil { 44 | apiError(resp, http.StatusInternalServerError, err.Error()) 45 | return 46 | } 47 | 48 | // Grab various bits of infomation from where we can 49 | info.Hostname, _ = os.Hostname() 50 | info.GoVersion = runtime.Version() 51 | info.OS = hostInfo.Platform + " " + hostInfo.PlatformVersion 52 | info.Platform = hostInfo.OS 53 | info.Uptime = hostInfo.Uptime 54 | info.Mem = memory.TotalMemory() 55 | info.Arch = runtime.GOARCH 56 | info.CPUs = runtime.NumCPU() 57 | info.CPUModel = cpuInfo[0].ModelName 58 | info.NetRemoteAddr = req.RemoteAddr 59 | info.NetHost = req.Host 60 | info.IsContainer = fileExists("/.dockerenv") 61 | info.IsKubernetes = fileExists("/var/run/secrets/kubernetes.io") 62 | 63 | if info.OS == " " { 64 | info.OS = "Unable to get host OS details" 65 | } 66 | 67 | if info.IsKubernetes { 68 | info.IsContainer = true 69 | } 70 | 71 | // Full grab of all env vars 72 | info.EnvVars = os.Environ() 73 | 74 | // Basic attempt to remove sensitive vars 75 | // Strange for-loop, means we can delete elements while looping over 76 | for i := len(info.EnvVars) - 1; i >= 0; i-- { 77 | envVarName := strings.Split(info.EnvVars[i], "=")[0] 78 | if strings.Contains(envVarName, "_KEY") || strings.Contains(envVarName, "SECRET") || strings.Contains(envVarName, "PWD") || strings.Contains(envVarName, "PASSWORD") { 79 | info.EnvVars = sliceRemove(info.EnvVars, i) 80 | } 81 | } 82 | 83 | // JSON-ify our info 84 | js, err := json.Marshal(info) 85 | if err != nil { 86 | apiError(resp, http.StatusInternalServerError, err.Error()) 87 | return 88 | } 89 | 90 | // Fire JSON result back down the internet tubes 91 | resp.Header().Set("Content-Type", "application/json") 92 | _, _ = resp.Write(js) 93 | } 94 | 95 | // Util to remove an element from a slice 96 | func sliceRemove(slice []string, i int) []string { 97 | if i < len(slice)-1 { 98 | slice = append(slice[:i], slice[i+1:]...) 99 | } else if i == len(slice)-1 { 100 | slice = slice[:len(slice)-1] 101 | } 102 | return slice 103 | } 104 | 105 | // fileExists checks if a file or directory exists 106 | func fileExists(filename string) bool { 107 | info, err := os.Stat(filename) 108 | if os.IsNotExist(err) { 109 | return false 110 | } 111 | return info != nil 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 60 | 61 | 100 | 101 | 121 | -------------------------------------------------------------------------------- /.github/scripts/url-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | declare -i time=60 4 | declare -i delay=5 5 | declare -i count=5 6 | declare -i okCount=0 7 | declare -i elapsed=0 8 | declare isUp="false" 9 | 10 | # Display usage 11 | usage(){ 12 | echo -e "\e[32m╭──────────────────────────────────────────────────────────────╮" 13 | echo -e "│ 🌍 \e[94murl-check.sh \e[96mCheck URL endpoint for HTTP responses 🚀\e[32m │" 14 | echo -e "╰──────────────────────────────────────────────────────────────╯" 15 | echo -e "\n\e[95mParameters:\e[37m" 16 | echo -e " -u, --url \e[33mURL to check (required)\e[37m" 17 | echo -e " [-t, --time] \e[33mMaximum number of seconds to poll for \e[92m(default: 60)\e[37m" 18 | echo -e " [-d, --delay] \e[33mDelay in seconds between requests \e[92m(default: 5)\e[37m" 19 | echo -e " [-c, --count] \e[33mHow many successes to receive before exiting \e[92m(default: 5)\e[37m" 20 | echo -e " [-s, --search] \e[33mOptional content check, grep for this string in HTTP body \e[92m(default: none)\e[37m" 21 | echo -e " [-h, --help] \e[33mShow this help text\e[37m" 22 | } 23 | 24 | OPTS=`getopt -o u:t:d:c:s:h --long url:,time:,delay:,count:,search:,help -n 'parse-options' -- "$@"` 25 | if [ $? != 0 ] ; then echo "Failed parsing options." >&2 ; usage; exit 1 ; fi 26 | eval set -- "$OPTS" 27 | 28 | while true; do 29 | case "$1" in 30 | -u | --url ) url="$2"; shift; shift;; 31 | -t | --time ) time="$2"; shift; shift;; 32 | -d | --delay ) delay="$2"; shift; shift;; 33 | -c | --count ) count="$2"; shift; shift;; 34 | -s | --search ) search="$2"; shift; shift;; 35 | -h | --help ) HELP=true; shift ;; 36 | -- ) shift; break ;; 37 | * ) break ;; 38 | esac 39 | done 40 | 41 | if [[ ${HELP} = true ]] || [ -z ${url} ]; then 42 | usage 43 | exit 0 44 | fi 45 | 46 | # Check for impossible parameter combination ie. too many checks and delays in given time limit 47 | if (( $delay * $count > $time)); then 48 | echo -e "\e[31m### Error! The time ($time) provided is too short given the delay ($delay) and count ($count)\e[0m" 49 | exit 1 50 | fi 51 | 52 | echo -e "\n\e[36m### Polling \e[33m$url\e[36m for ${time}s, to get $count OK results, with a ${delay}s delay\e[0m\n" 53 | 54 | # Generate tmp filename 55 | tmpfile=$(echo $url | md5sum) 56 | 57 | # Main loop 58 | while [ "$isUp" != "true" ] 59 | do 60 | # Break out of loop if max time has elapsed 61 | if (( $elapsed >= $time )); then break; fi 62 | timestamp=$(date "+%Y/%m/%d %H:%M:%S") 63 | 64 | # Main CURL test, output to file and return http_code 65 | urlstatus=$(curl -o "/tmp/$tmpfile" --silent --write-out '%{http_code}' "$url") 66 | 67 | if [ $urlstatus -eq 000 ]; then 68 | # Code 000 means DNS, network error or malformed URL 69 | msg="\e[95mSite not found or other error" 70 | else 71 | if (( $urlstatus >= 200 )) && (( $urlstatus < 300 )); then 72 | # Check returned content with grep if check specified 73 | if [ ! -z "$search" ]; then 74 | grep -q "$search" "/tmp/$tmpfile" 75 | # Only count as a success if string grep passed 76 | if (( $? == 0)); then 77 | ((okCount=okCount + 1)) 78 | msg="✅ \e[32m$urlstatus 🔍 Content check for '$search' passed" 79 | else 80 | msg="❌ \e[91m$urlstatus 🔍 Content check for '$search' failed" 81 | fi 82 | else 83 | # Good status code 84 | ((okCount=okCount + 1)) 85 | msg="✅ \e[32m$urlstatus " 86 | fi 87 | 88 | if (( $okCount >= $count )); then isUp="true"; fi 89 | else 90 | # Bad status code 91 | msg="❌ \e[91m$urlstatus " 92 | fi 93 | fi 94 | 95 | # Output message + timestamp then delay 96 | echo -e "### $timestamp: $msg\e[0m" 97 | sleep $delay 98 | ((elapsed=elapsed + delay)) 99 | done 100 | 101 | rm "/tmp/$tmpfile" 102 | # Final result check 103 | if [ "$isUp" == "true" ]; then 104 | echo -e "\n\e[32m### Result: $url is UP! 🤩\e[0m" 105 | exit 0 106 | else 107 | echo -e "\n\e[91m### Result: $url is DOWN! 😢\e[0m" 108 | exit 1 109 | fi 110 | -------------------------------------------------------------------------------- /tests/postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "e715c092-9681-4556-a6d2-3c21e7d6ae96", 4 | "name": "VueGo Demoapp", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Check Home Page", 10 | "event": [ 11 | { 12 | "listen": "test", 13 | "script": { 14 | "exec": [ 15 | "pm.test(\"Home Page: Successful GET request\", function () {", 16 | " pm.response.to.be.ok;", 17 | "});", 18 | "", 19 | "pm.test(\"Home Page: Response valid & HTML body\", function () {", 20 | " pm.response.to.be.withBody;", 21 | " pm.expect(pm.response.headers.get('Content-Type')).to.contain('text/html');", 22 | "});", 23 | "", 24 | "pm.test(\"Home Page: Check content\", function () {", 25 | " pm.expect(pm.response.text()).to.include('Vue.js');", 26 | "});", 27 | "" 28 | ], 29 | "type": "text/javascript" 30 | } 31 | } 32 | ], 33 | "request": { 34 | "method": "GET", 35 | "header": [], 36 | "url": { 37 | "raw": "{{BASE_URL}}/", 38 | "host": [ 39 | "{{BASE_URL}}" 40 | ], 41 | "path": [ 42 | "" 43 | ] 44 | } 45 | }, 46 | "response": [] 47 | }, 48 | { 49 | "name": "Check Weather API", 50 | "event": [ 51 | { 52 | "listen": "test", 53 | "script": { 54 | "exec": [ 55 | "pm.test(\"Weather API: Successful GET request\", function () {", 56 | " pm.response.to.be.ok;", 57 | "});", 58 | "", 59 | "pm.test(\"Weather API: Response valid & JSON body\", function () {", 60 | " pm.response.to.be.withBody;", 61 | " pm.response.to.be.json;", 62 | "});", 63 | "", 64 | "pm.test(\"Weather API: Check API response\", function () {", 65 | " var weatherData = pm.response.json();", 66 | " pm.expect(weatherData.weather).to.exist;", 67 | " pm.expect(weatherData.main).to.exist;", 68 | " pm.expect(weatherData.weather).to.be.an('array')", 69 | " pm.expect(weatherData.main.temp).to.be.an('number')", 70 | "});", 71 | "" 72 | ], 73 | "type": "text/javascript" 74 | } 75 | } 76 | ], 77 | "request": { 78 | "method": "GET", 79 | "header": [], 80 | "url": { 81 | "raw": "{{BASE_URL}}/api/weather/51.5072/0.1276", 82 | "host": [ 83 | "{{BASE_URL}}" 84 | ], 85 | "path": [ 86 | "api", 87 | "weather", 88 | "51.5072", 89 | "0.1276" 90 | ] 91 | } 92 | }, 93 | "response": [] 94 | }, 95 | { 96 | "name": "Check Mnitor API", 97 | "event": [ 98 | { 99 | "listen": "test", 100 | "script": { 101 | "exec": [ 102 | "pm.test(\"Metric API: Successful GET request\", function () {", 103 | " pm.response.to.be.ok;", 104 | "});", 105 | "", 106 | "pm.test(\"Metric API: Response valid & JSON body\", function () {", 107 | " pm.response.to.be.withBody;", 108 | " pm.response.to.be.json;", 109 | "});", 110 | "", 111 | "pm.test(\"Metric API: Check API response\", function () {", 112 | " var metricData = pm.response.json();", 113 | " pm.expect(metricData).to.be.an('object')", 114 | " pm.expect(metricData.memTotal).to.be.an('number')", 115 | " pm.expect(metricData.cpuPerc).to.be.an('number')", 116 | " pm.expect(metricData.netBytesSent).to.be.an('number')", 117 | "});", 118 | "" 119 | ], 120 | "type": "text/javascript" 121 | } 122 | } 123 | ], 124 | "request": { 125 | "method": "GET", 126 | "header": [], 127 | "url": { 128 | "raw": "{{BASE_URL}}/api/monitor", 129 | "host": [ 130 | "{{BASE_URL}}" 131 | ], 132 | "path": [ 133 | "api", 134 | "monitor" 135 | ] 136 | } 137 | }, 138 | "response": [] 139 | } 140 | ], 141 | "event": [ 142 | { 143 | "listen": "prerequest", 144 | "script": { 145 | "type": "text/javascript", 146 | "exec": [ 147 | "" 148 | ] 149 | } 150 | }, 151 | { 152 | "listen": "test", 153 | "script": { 154 | "type": "text/javascript", 155 | "exec": [ 156 | "" 157 | ] 158 | } 159 | } 160 | ] 161 | } -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | # Used by `image`, `push` & `deploy` targets, override as required 2 | IMAGE_REG ?= ghcr.io 3 | IMAGE_REPO ?= benc-uk/vuego-demoapp 4 | IMAGE_TAG ?= latest 5 | 6 | # Used by `deploy` target, sets Azure webap defaults, override as required 7 | AZURE_RES_GROUP ?= demoapps 8 | AZURE_REGION ?= northeurope 9 | AZURE_APP_NAME ?= vuego-demoapp 10 | 11 | # Used by `test-api` target 12 | TEST_HOST ?= localhost:4000 13 | 14 | # Don't change 15 | FRONT_DIR := frontend 16 | SERVER_DIR := server 17 | REPO_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) 18 | GOLINT_PATH := $(REPO_DIR)/bin/golangci-lint 19 | 20 | .PHONY: help lint lint-fix image push run deploy undeploy clean test test-api test-report test-snapshot watch-server watch-spa .EXPORT_ALL_VARIABLES 21 | .DEFAULT_GOAL := help 22 | 23 | help: ## 💬 This help message 24 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 25 | 26 | lint: $(FRONT_DIR)/node_modules ## 🔎 Lint & format, will not fix but sets exit code on error 27 | @$(GOLINT_PATH) > /dev/null || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh 28 | cd $(SERVER_DIR); $(GOLINT_PATH) run --modules-download-mode=mod ./... 29 | cd $(FRONT_DIR); npm run lint 30 | 31 | lint-fix: $(FRONT_DIR)/node_modules ## 📜 Lint & format, will try to fix errors and modify code 32 | @$(GOLINT_PATH) > /dev/null || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh 33 | cd $(SERVER_DIR); golangci-lint run --modules-download-mode=mod *.go --fix 34 | cd $(FRONT_DIR); npm run lint-fix 35 | 36 | image: ## 🔨 Build container image from Dockerfile 37 | docker build . --file build/Dockerfile \ 38 | --tag $(IMAGE_REG)/$(IMAGE_REPO):$(IMAGE_TAG) 39 | 40 | push: ## 📤 Push container image to registry 41 | docker push $(IMAGE_REG)/$(IMAGE_REPO):$(IMAGE_TAG) 42 | 43 | run: $(FRONT_DIR)/node_modules ## 🏃 Run BOTH components locally using Vue CLI and Go server backend 44 | cd $(SERVER_DIR); go run ./cmd & 45 | cd $(FRONT_DIR); npm run serve 46 | 47 | watch-server: ## 👀 Run API server with hot reload file watcher, needs cosmtrek/air 48 | cd $(SERVER_DIR); air -c .air.toml 49 | 50 | watch-frontend: $(FRONT_DIR)/node_modules ## 👀 Run frontend with hot reload file watcher 51 | cd $(FRONT_DIR); npm run serve 52 | 53 | build-frontend: $(FRONT_DIR)/node_modules ## 🧰 Build and bundle the frontend into dist 54 | cd $(FRONT_DIR); npm run build 55 | 56 | deploy: ## 🚀 Deploy to Azure Container Apps 57 | az group create --resource-group $(AZURE_RES_GROUP) --location $(AZURE_REGION) -o table 58 | az deployment group create --template-file deploy/container-app.bicep \ 59 | --resource-group $(AZURE_RES_GROUP) \ 60 | --parameters appName=$(AZURE_APP_NAME) \ 61 | --parameters image=$(IMAGE_REG)/$(IMAGE_REPO):$(IMAGE_TAG) -o table 62 | @echo "### 🚀 App deployed & available here: $(shell az deployment group show --resource-group vuego-demoapp --name container-app --query "properties.outputs.appURL.value" -o tsv)/" 63 | 64 | undeploy: ## 💀 Remove from Azure 65 | @echo "### WARNING! Going to delete $(AZURE_RES_GROUP) 😲" 66 | az group delete -n $(AZURE_RES_GROUP) -o table --no-wait 67 | 68 | test: $(FRONT_DIR)/node_modules ## 🎯 Unit tests for server and frontend 69 | cd $(SERVER_DIR); go test -v ./... 70 | cd $(FRONT_DIR); npm run test 71 | 72 | test-report: $(FRONT_DIR)/node_modules ## 📜 Unit tests for server and frontend with report 73 | go get -u github.com/vakenbolt/go-test-report 74 | cd $(SERVER_DIR); go test -json ./... | $(shell go env GOPATH)/bin/go-test-report --output test-report.html 75 | cd $(FRONT_DIR); npm run test-report 76 | 77 | test-snapshot: ## 📷 Update snapshots for frontend tests 78 | cd $(FRONT_DIR); npm run test-update 79 | 80 | test-api: $(FRONT_DIR)/node_modules .EXPORT_ALL_VARIABLES ## 🚦 Run integration API tests, server must be running 81 | $(FRONT_DIR)/node_modules/.bin/newman run tests/postman_collection.json --env-var BASE_URL=$(TEST_HOST) 82 | 83 | clean: ## 🧹 Clean up project 84 | rm -rf $(FRONT_DIR)/dist 85 | rm -rf $(FRONT_DIR)/node_modules 86 | rm -rf $(SERVER_DIR)/test*.html 87 | rm -rf $(FRONT_DIR)/test*.html 88 | rm -rf $(FRONT_DIR)/coverage 89 | rm -rf $(REPO_DIR)/bin 90 | 91 | # ============================================================================ 92 | 93 | $(FRONT_DIR)/node_modules: $(FRONT_DIR)/package.json 94 | cd $(FRONT_DIR); npm install --silent 95 | touch -m $(FRONT_DIR)/node_modules 96 | 97 | $(FRONT_DIR)/package.json: 98 | @echo "package.json was modified" 99 | -------------------------------------------------------------------------------- /frontend/src/components/Weather.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 65 | 66 | 136 | 137 | 145 | -------------------------------------------------------------------------------- /frontend/src/components/Info.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 83 | 84 | 148 | 149 | 180 | -------------------------------------------------------------------------------- /frontend/src/services/auth.js: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Copyright (c) Ben Coleman, 2021 3 | // Licensed under the MIT License. 4 | // 5 | // Drop in MSAL.js 2.x service wrapper & helper for SPAs 6 | // v2.1.0 - Ben Coleman 2019 7 | // Updated 2021 - Switched to @azure/msal-browser 8 | // ---------------------------------------------------------------------------- 9 | 10 | import * as msal from '@azure/msal-browser' 11 | 12 | // MSAL object used for signing in users with MS identity platform 13 | let msalApp 14 | 15 | export default { 16 | // 17 | // Configure with clientId or empty string/null to set in "demo" mode 18 | // 19 | async configure(clientId, enableDummyUser = true) { 20 | // Can only call configure once 21 | if (msalApp) { 22 | return 23 | } 24 | 25 | // If no clientId provided & enableDummyUser then create a mock MSAL UserAgentApplication 26 | // Allows us to run without Azure AD for demos & local dev 27 | if (!clientId && enableDummyUser) { 28 | console.log('### Azure AD sign-in: disabled. Will run in demo mode with dummy demo@example.net account') 29 | 30 | const dummyUser = { 31 | accountIdentifier: 'e11d4d0c-1c70-430d-a644-aed03a60e059', 32 | homeAccountIdentifier: '', 33 | username: 'demo@example.net', 34 | name: 'Demo User', 35 | idToken: null, 36 | sid: '', 37 | environment: '', 38 | idTokenClaims: { 39 | tid: 'fake-tenant' 40 | } 41 | } 42 | 43 | // Stub out all the functions we call and return static dummy user where required 44 | // Use localStorage to simulate MSAL caching and logging out 45 | msalApp = { 46 | clientId: null, 47 | 48 | loginPopup() { 49 | localStorage.setItem('dummyAccount', JSON.stringify(dummyUser)) 50 | return new Promise((resolve) => resolve()) 51 | }, 52 | logout() { 53 | localStorage.removeItem('dummyAccount') 54 | window.location.href = '/' 55 | return new Promise((resolve) => resolve()) 56 | }, 57 | acquireTokenSilent() { 58 | return new Promise((resolve) => resolve({ accessToken: '1234567890' })) 59 | }, 60 | cacheStorage: { 61 | clear() { 62 | localStorage.removeItem('dummyAccount') 63 | } 64 | }, 65 | getAllAccounts() { 66 | return [JSON.parse(localStorage.getItem('dummyAccount'))] 67 | } 68 | } 69 | return 70 | } 71 | 72 | // Can't configure if clientId blank/null/undefined 73 | if (!clientId) { 74 | return 75 | } 76 | 77 | const config = { 78 | auth: { 79 | clientId: clientId, 80 | redirectUri: window.location.origin, 81 | authority: 'https://login.microsoftonline.com/common' 82 | }, 83 | cache: { 84 | cacheLocation: 'localStorage' 85 | } 86 | // Only uncomment when you *really* need to debug what is going on in MSAL 87 | /* system: { 88 | logger: new msal.Logger( 89 | (logLevel, msg) => { console.log(msg) }, 90 | { 91 | level: msal.LogLevel.Verbose 92 | } 93 | ) 94 | } */ 95 | } 96 | console.log('### Azure AD sign-in: enabled\n', config) 97 | 98 | // Create our shared/static MSAL app object 99 | msalApp = new msal.PublicClientApplication(config) 100 | }, 101 | 102 | // 103 | // Return the configured client id 104 | // 105 | clientId() { 106 | if (!msalApp) { 107 | return null 108 | } 109 | 110 | return msalApp.clientId 111 | }, 112 | 113 | // 114 | // Login a user with a popup 115 | // 116 | async login(scopes = ['user.read', 'openid', 'profile', 'email']) { 117 | if (!msalApp) { 118 | return 119 | } 120 | 121 | await msalApp.loginPopup({ 122 | scopes, 123 | prompt: 'select_account' 124 | }) 125 | }, 126 | 127 | // 128 | // Logout any stored user 129 | // 130 | logout() { 131 | if (!msalApp) { 132 | return 133 | } 134 | 135 | msalApp.logoutPopup() 136 | }, 137 | 138 | // 139 | // Call to get user, probably cached and stored locally by MSAL 140 | // 141 | user() { 142 | if (!msalApp) { 143 | return null 144 | } 145 | 146 | const currentAccounts = msalApp.getAllAccounts() 147 | if (!currentAccounts || currentAccounts.length === 0) { 148 | // No user signed in 149 | return null 150 | } else if (currentAccounts.length > 1) { 151 | return currentAccounts[0] 152 | } else { 153 | return currentAccounts[0] 154 | } 155 | }, 156 | 157 | // 158 | // Call through to acquireTokenSilent or acquireTokenPopup 159 | // 160 | async acquireToken(scopes = ['user.read']) { 161 | if (!msalApp) { 162 | return null 163 | } 164 | 165 | // Set scopes for token request 166 | const accessTokenRequest = { 167 | scopes, 168 | account: this.user() 169 | } 170 | 171 | let tokenResp 172 | try { 173 | // 1. Try to acquire token silently 174 | tokenResp = await msalApp.acquireTokenSilent(accessTokenRequest) 175 | console.log('### MSAL acquireTokenSilent was successful') 176 | } catch (err) { 177 | // 2. Silent process might have failed so try via popup 178 | tokenResp = await msalApp.acquireTokenPopup(accessTokenRequest) 179 | console.log('### MSAL acquireTokenPopup was successful') 180 | } 181 | 182 | // Just in case check, probably never triggers 183 | if (!tokenResp.accessToken) { 184 | throw new Error("### accessToken not found in response, that's bad") 185 | } 186 | 187 | return tokenResp.accessToken 188 | }, 189 | 190 | // 191 | // Clear any stored/cached user 192 | // 193 | clearLocal() { 194 | if (msalApp) { 195 | for (const entry of Object.entries(localStorage)) { 196 | const key = entry[0] 197 | if (key.includes('login.windows')) { 198 | localStorage.removeItem(key) 199 | } 200 | } 201 | } 202 | }, 203 | 204 | // 205 | // Check if we have been setup & configured 206 | // 207 | isConfigured() { 208 | return msalApp != null 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Go & Vue.js - Demo Web Application 2 | 3 | This is a simple web application with a Go server/backend and a Vue.js SPA (Single Page Application) frontend. 4 | 5 | The app has been designed with cloud native demos & containers in mind, in order to provide a real working application for deployment, something more than "hello-world" but with the minimum of pre-reqs. It is not intended as a complete example of a fully functioning architecture or complex software design. 6 | 7 | Typical uses would be deployment to Kubernetes, demos of Docker, CI/CD (build pipelines are provided), deployment to cloud (Azure) monitoring, auto-scaling 8 | 9 | - The Frontend is a SPA written in Vue.js 3. It uses [Bootstrap 5](https://getbootstrap.com/) and [Font Awesome](https://fontawesome.com/). In addition [Gauge.js](http://bernii.github.io/gauge.js/) is used for the dials in the monitoring view 10 | - The Go component is a Go HTTP server based on the std http package and using [gopsutils](https://github.com/shirou/gopsutil) for monitoring metrics, and [Gorilla Mux](https://github.com/gorilla/mux) for routing 11 | 12 | Features: 13 | 14 | - System status / information view 15 | - Geolocated weather info (from OpenWeather API) 16 | - Realtime monitoring and metric view 17 | - Support for user authentication with Azure AD and MSAL 18 | - Prometheus metrics 19 | - API for generating CPU load, and allocating memory 20 | 21 | 22 | 23 | 24 | 25 | 26 | # Status 27 | 28 | ![](https://img.shields.io/github/last-commit/benc-uk/vuego-demoapp) ![](https://img.shields.io/github/release-date/benc-uk/vuego-demoapp) ![](https://img.shields.io/github/v/release/benc-uk/vuego-demoapp) ![](https://img.shields.io/github/commit-activity/y/benc-uk/vuego-demoapp) 29 | 30 | Live instance: 31 | 32 | [![](https://img.shields.io/website?label=Hosted%3A%20Kubernetes&up_message=online&url=https%3A%2F%2Fvuego-demoapp.kube.benco.io%2F)](https://vuego-demoapp.kube.benco.io/) 33 | 34 | ## Repo Structure 35 | 36 | ```txt 37 | / 38 | ├── frontend Root of the Vue.js project 39 | │   └── src Vue.js source code 40 | │   └── tests Unit tests 41 | ├── deploy Supporting files for Azure deployment etc 42 | │ └── kubernetes Instructions for Kubernetes deployment with Helm 43 | ├── server Go backend server 44 | │   └── cmd Server main / exec 45 | │   └── pkg Supporting packages 46 | ├── build Supporting build scripts and Dockerfile 47 | └── test API / integration tests 48 | ``` 49 | 50 | ## Server API 51 | 52 | The Go server component performs two tasks 53 | 54 | - Serve the Vue.js app to the user. As this is a SPA, this is static content, i.e. HTML, JS & CSS files and any images. Note. The Vue.js app needs to be 'built' before it can be served, this bundles everything up correctly. 55 | - Provide a simple REST API for data to be displayed & rendered by the Vue.js app. This API is very simple currently has three routes: 56 | - `GET /api/info` - Returns system information and various properties as JSON 57 | - `GET /api/monitor` - Returns monitoring metrics for CPU, memory, disk and network. This data comes from the _gopsutils_ library 58 | - `GET /api/weather/{lat}/{long}` - Returns weather data from OpenWeather API 59 | - `GET /api/gc` - Force the garbage collector to run 60 | - `POST /api/alloc` - Allocate a lump of memory, payload `{"size":int}` 61 | - `POST /api/cpu` - Force CPU load, payload `{"seconds":int}` 62 | 63 | In addition to these application specific endpoints, the following REST operations are supported: 64 | 65 | - `GET /api/status` - Status and information about the service 66 | - `GET /api/health` - A health endpoint, returns HTTP 200 when OK 67 | - `GET /api/metrics` - Returns low level system and HTTP performance metrics for scraping with Prometheus 68 | 69 | ## Building & Running Locally 70 | 71 | ### Pre-reqs 72 | 73 | - Be using Linux, WSL or MacOS, with bash, make etc 74 | - [Node.js](https://nodejs.org/en/) [Go 1.16+](https://golang.org/doc/install) - for running locally, linting, running tests etc 75 | - [cosmtrek/air](https://github.com/cosmtrek/air#go) - if using `make watch-server` 76 | - [Docker](https://docs.docker.com/get-docker/) - for running as a container, or image build and push 77 | - [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-linux) - for deployment to Azure 78 | 79 | Clone the project to any directory where you do development work 80 | 81 | ``` 82 | git clone https://github.com/benc-uk/vuego-demoapp.git 83 | ``` 84 | 85 | ### Makefile 86 | 87 | A standard GNU Make file is provided to help with running and building locally. 88 | 89 | ```text 90 | help 💬 This help message 91 | lint 🔎 Lint & format, will not fix but sets exit code on error 92 | lint-fix 📜 Lint & format, will try to fix errors and modify code 93 | image 🔨 Build container image from Dockerfile 94 | push 📤 Push container image to registry 95 | run 🏃 Run BOTH components locally using Vue CLI and Go server backend 96 | watch-server 👀 Run API server with hot reload file watcher, needs cosmtrek/air 97 | watch-frontend 👀 Run frontend with hot reload file watcher 98 | build-frontend 🧰 Build and bundle the frontend into dist 99 | deploy 🚀 Deploy to Azure Container Apps 100 | undeploy 💀 Remove from Azure 101 | test 🎯 Unit tests for server and frontend 102 | test-report 🎯 Unit tests for server and frontend (with report output) 103 | test-snapshot 📷 Update snapshots for frontend tests 104 | test-api 🚦 Run integration API tests, server must be running 105 | clean 🧹 Clean up project 106 | ``` 107 | 108 | Make file variables and default values, pass these in when calling `make`, e.g. `make image IMAGE_REPO=blah/foo` 109 | 110 | | Makefile Variable | Default | 111 | | ----------------- | --------------------- | 112 | | IMAGE_REG | ghcr.io | 113 | | IMAGE_REPO | benc-uk/vuego-demoapp | 114 | | IMAGE_TAG | latest | 115 | | AZURE_RES_GROUP | temp-demoapps | 116 | | AZURE_REGION | uksouth | 117 | 118 | - The server will listen on port 4000 by default, change this by setting the environmental variable `PORT` 119 | - The server will ry to serve static content (i.e. bundled frontend) from the same directory as the server binary, change this by setting the environmental variable `CONTENT_DIR` 120 | - The frontend will use `/api` as the API endpoint, when working locally `VUE_APP_API_ENDPOINT` is set and overrides this to be `http://localhost:4000/api` 121 | 122 | # Containers 123 | 124 | Public container image is [available on GitHub Container Registry](https://github.com/users/benc-uk/packages/container/package/vuego-demoapp) 125 | 126 | Run in a container with: 127 | 128 | ```bash 129 | docker run --rm -it -p 4000:4000 ghcr.io/benc-uk/vuego-demoapp:latest 130 | ``` 131 | 132 | Should you want to build your own container, use `make image` and the above variables to customise the name & tag. 133 | 134 | ## Kubernetes 135 | 136 | The app can easily be deployed to Kubernetes using Helm, see [deploy/kubernetes/readme.md](deploy/kubernetes/readme.md) for details 137 | 138 | ## Running in Azure App Service (Linux) 139 | 140 | If you want to deploy to an Azure Web App as a container (aka Linux Web App), a Bicep template is provided in the [deploy](deploy/) directory 141 | 142 | For a super quick deployment, use `make deploy` which will deploy to a resource group, temp-demoapps and use the git ref to create a unique site name 143 | 144 | ```bash 145 | make deploy 146 | ``` 147 | 148 | # Config 149 | 150 | Environmental variables 151 | 152 | - `WEATHER_API_KEY` - Enable the weather feature with a OpenWeather API key 153 | - `PORT` - Port to listen on (default: `4000`) 154 | - `CONTENT_DIR` - Directory to serve static content from (default: `.`) 155 | - `AUTH_CLIENT_ID` - Set to a Azure AD registered app if you wish to enable the optional user sign-in feature 156 | 157 | ### Optional User Sign-In Feature 158 | 159 | The application can be configured with an optional user sign-in feature which uses Azure Active Directory as an identity platform. This uses wrapper & helper libraries from https://github.com/benc-uk/msal-graph-vue 160 | 161 | If you wish to enable this, carry out the following steps: 162 | 163 | - Register an application with Azure AD, [see these steps](https://github.com/benc-uk/msal-graph-vue#set-up--deployment) 164 | - Set the environmental variable `AUTH_CLIENT_ID` on the Go server, with the value of the client id. This can be done in the `.env` file if working locally. 165 | - _Optional_ when testing/debugging the Vue.js SPA without the Go server, you can place the client-id in `.env.development` under the value `VUE_APP_AUTH_CLIENT_ID` 166 | 167 | # GitHub Actions CI/CD 168 | 169 | A set of GitHub Actions workflows are included for CI / CD. Automated builds for PRs are run in GitHub hosted runners validating the code (linting and tests) and building dev images. When code is merged into master, then automated deployment to AKS is done using Helm. 170 | 171 | [![](https://img.shields.io/github/workflow/status/benc-uk/vuego-demoapp/CI%20Build%20App)](https://github.com/benc-uk/vuego-demoapp/actions?query=workflow%3A%22CI+Build+App%22) [![](https://img.shields.io/github/workflow/status/benc-uk/vuego-demoapp/CD%20Release%20-%20AKS?label=release-kubernetes)](https://github.com/benc-uk/vuego-demoapp/actions?query=workflow%3A%22CD+Release+-+AKS%22) 172 | 173 | ## Updates 174 | 175 | | When | What | 176 | | ---------- | ---------------------------------------------------- | 177 | | Nov 2021 | Rewrite for Vue.js 3, new look & feel, huge refactor | 178 | | Mar 2021 | Auth using MSAL.js v2 added | 179 | | Mar 2021 | Refresh, makefile, more tests | 180 | | Nov 2020 | New pipelines & code/ API robustness | 181 | | Dec 2019 | Github Actions and AKS | 182 | | Sept 2019 | New release pipelines and config moved to env vars | 183 | | Sept 2018 | Updated with weather API and weather view | 184 | | July 2018 | Updated Vue CLI config & moved to Golang 1.11 | 185 | | April 2018 | Project created | 186 | -------------------------------------------------------------------------------- /server/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= 3 | github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= 4 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 5 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 6 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 7 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 8 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 9 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 10 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 11 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 12 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 13 | github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= 14 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/elastic/go-sysinfo v1.7.1 h1:Wx4DSARcKLllpKT2TnFVdSUJOsybqMYCNQZq1/wO+s0= 19 | github.com/elastic/go-sysinfo v1.7.1/go.mod h1:i1ZYdU10oLNfRzq4vq62BEwD2fH8KaWh6eh0ikPT9F0= 20 | github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= 21 | github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= 22 | github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= 23 | github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 24 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 25 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 26 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 27 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 28 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 29 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 30 | github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 31 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 32 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 33 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 34 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 35 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 36 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 37 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 38 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 39 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 40 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 41 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 42 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 43 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 44 | github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= 45 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 46 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 47 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 48 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 49 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 50 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 51 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 52 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 53 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 54 | github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= 55 | github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= 56 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 57 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 58 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 59 | github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= 60 | github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= 61 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 62 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 63 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 64 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 65 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 66 | github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 67 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 68 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 69 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 70 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 71 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 72 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 73 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 74 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 75 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 76 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 77 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 78 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 79 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 81 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 82 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 83 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 84 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 85 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= 86 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= 87 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 88 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 89 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 90 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 91 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 93 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 94 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 95 | github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= 96 | github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= 97 | github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= 98 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 99 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 100 | github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= 101 | github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 102 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 103 | github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= 104 | github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= 105 | github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= 106 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 107 | github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 108 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 109 | github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= 110 | github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= 111 | github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= 112 | github.com/shirou/gopsutil v3.21.10+incompatible h1:AL2kpVykjkqeN+MFe1WcwSBVUjGjvdU8/ubvCuXAjrU= 113 | github.com/shirou/gopsutil v3.21.10+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= 114 | github.com/shirou/gopsutil/v3 v3.21.10 h1:flTg1DrnV/UVrBqjLgVgDJzx6lf+91rC64/dBHmO2IA= 115 | github.com/shirou/gopsutil/v3 v3.21.10/go.mod h1:t75NhzCZ/dYyPQjyQmrAYP6c8+LCdFANeBMdLPCNnew= 116 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 117 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 118 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 119 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 120 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 121 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 122 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 123 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 124 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 125 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 126 | github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= 127 | github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= 128 | github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ= 129 | github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= 130 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 131 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 132 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 133 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 134 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 135 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 136 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 137 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 138 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 139 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 140 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 141 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 142 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 143 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 144 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 145 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 146 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 147 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 148 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 149 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.0.0-20211013075003-97ac67df715c h1:taxlMj0D/1sOAuv/CbSD+MMDof2vbyPTqz5FNYKpXt8= 159 | golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 160 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 161 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 162 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 163 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 164 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 165 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 166 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 167 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 168 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 169 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 170 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 171 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 172 | google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ= 173 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 174 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 175 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 176 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 177 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 178 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 179 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 180 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 181 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 182 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 183 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 184 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 185 | howett.net/plist v0.0.0-20181124034731-591f970eefbb h1:jhnBjNi9UFpfpl8YZhA9CrOqpnJdvzuiHsl/dnxl11M= 186 | howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= 187 | --------------------------------------------------------------------------------