diff --git a/.github/workflows/call-docker-build-result.yaml b/.github/workflows/call-docker-build-result.yaml index a946a87b03..264cce9455 100644 --- a/.github/workflows/call-docker-build-result.yaml +++ b/.github/workflows/call-docker-build-result.yaml @@ -5,77 +5,51 @@ on: # we want pull requests so we can build(test) but not push to image registry push: branches: - - 'main' + - 'dev' # only build when important files change paths: - 'result/**' - '.github/workflows/call-docker-build-result.yaml' pull_request: branches: - - 'main' + - 'dev' # only build when important files change paths: - 'result/**' - '.github/workflows/call-docker-build-result.yaml' jobs: - call-docker-build: - - name: Result Call Docker Build - - uses: dockersamples/.github/.github/workflows/reusable-docker-build.yaml@main - - permissions: - contents: read - packages: write # needed to push docker image to ghcr.io - pull-requests: write # needed to create and update comments in PRs - - secrets: - - # Only needed if with:dockerhub-enable is true below - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - - # Only needed if with:dockerhub-enable is true below - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - with: - - ### REQUIRED - ### ENABLE ONE OR BOTH REGISTRIES - ### tell docker where to push. - ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true - ghcr-enable: true - - ### REQUIRED - ### A list of the account/repo names for docker build. List should match what's enabled above - ### defaults to: - image-names: | - ghcr.io/dockersamples/example-voting-app-result - dockersamples/examplevotingapp_result - - ### REQUIRED set rules for tagging images, based on special action syntax: - ### https://github.com/docker/metadata-action#tags-input - ### defaults to: - tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=before,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=after,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=pr - - ### path to where docker should copy files into image - ### defaults to root of repository (.) - context: result - - ### Dockerfile alternate name. Default is Dockerfile (relative to context path) - # file: Containerfile - - ### build stage to target, defaults to empty, which builds to last stage in Dockerfile - # target: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - ### platforms to build for, defaults to linux/amd64 - ### other options: linux/amd64,linux/arm64,linux/arm/v7 - platforms: linux/amd64,linux/arm64,linux/arm/v7 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push result image + uses: docker/build-push-action@v5 + with: + context: ./result + push: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: | + paranormalrave/shedeploys-result:latest + ghcr.io/scagroup6/example-voting-app-result:latest ### Create a PR comment with image tags and labels ### defaults to false diff --git a/.github/workflows/call-docker-build-vote.yaml b/.github/workflows/call-docker-build-vote.yaml index cb4a484a2a..3fa6165557 100644 --- a/.github/workflows/call-docker-build-vote.yaml +++ b/.github/workflows/call-docker-build-vote.yaml @@ -5,77 +5,52 @@ on: # we want pull requests so we can build(test) but not push to image registry push: branches: - - 'main' + - 'dev' # only build when important files change paths: - 'vote/**' - '.github/workflows/call-docker-build-vote.yaml' pull_request: branches: - - 'main' + - 'dev' # only build when important files change paths: - 'vote/**' - '.github/workflows/call-docker-build-vote.yaml' jobs: - call-docker-build: - - name: Vote Call Docker Build - - uses: dockersamples/.github/.github/workflows/reusable-docker-build.yaml@main - - permissions: - contents: read - packages: write # needed to push docker image to ghcr.io - pull-requests: write # needed to create and update comments in PRs - - secrets: - - # Only needed if with:dockerhub-enable is true below - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - - # Only needed if with:dockerhub-enable is true below - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - with: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - ### REQUIRED - ### ENABLE ONE OR BOTH REGISTRIES - ### tell docker where to push. - ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true - ghcr-enable: true + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push vote image + uses: docker/build-push-action@v5 + with: + context: ./vote + push: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: | + paranormalrave/shedeploys-vote:latest + ghcr.io/scagroup6/example-voting-app-vote:latest - ### REQUIRED - ### A list of the account/repo names for docker build. List should match what's enabled above - ### defaults to: - image-names: | - ghcr.io/dockersamples/example-voting-app-vote - dockersamples/examplevotingapp_vote - - ### REQUIRED set rules for tagging images, based on special action syntax: - ### https://github.com/docker/metadata-action#tags-input - ### defaults to: - tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=before,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=after,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=pr - - ### path to where docker should copy files into image - ### defaults to root of repository (.) - context: vote - - ### Dockerfile alternate name. Default is Dockerfile (relative to context path) - # file: Containerfile - - ### build stage to target, defaults to empty, which builds to last stage in Dockerfile - # target: - - ### platforms to build for, defaults to linux/amd64 - ### other options: linux/amd64,linux/arm64,linux/arm/v7 - platforms: linux/amd64,linux/arm64,linux/arm/v7 ### Create a PR comment with image tags and labels ### defaults to false diff --git a/.github/workflows/call-docker-build-worker.yaml b/.github/workflows/call-docker-build-worker.yaml index 5abfb6bc9c..690e8f6272 100644 --- a/.github/workflows/call-docker-build-worker.yaml +++ b/.github/workflows/call-docker-build-worker.yaml @@ -5,77 +5,51 @@ on: # we want pull requests so we can build(test) but not push to image registry push: branches: - - 'main' + - 'dev' # only build when important files change paths: - 'worker/**' - '.github/workflows/call-docker-build-worker.yaml' pull_request: branches: - - 'main' + - 'dev' # only build when important files change paths: - 'worker/**' - '.github/workflows/call-docker-build-worker.yaml' jobs: - call-docker-build: - - name: Worker Call Docker Build - - uses: dockersamples/.github/.github/workflows/reusable-docker-build.yaml@main - - permissions: - contents: read - packages: write # needed to push docker image to ghcr.io - pull-requests: write # needed to create and update comments in PRs - - secrets: - - # Only needed if with:dockerhub-enable is true below - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - - # Only needed if with:dockerhub-enable is true below - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - with: - - ### REQUIRED - ### ENABLE ONE OR BOTH REGISTRIES - ### tell docker where to push. - ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true - ghcr-enable: true - - ### REQUIRED - ### A list of the account/repo names for docker build. List should match what's enabled above - ### defaults to: - image-names: | - ghcr.io/dockersamples/example-voting-app-worker - dockersamples/examplevotingapp_worker - - ### REQUIRED set rules for tagging images, based on special action syntax: - ### https://github.com/docker/metadata-action#tags-input - ### defaults to: - tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=pr - - ### path to where docker should copy files into image - ### defaults to root of repository (.) - context: worker - - ### Dockerfile alternate name. Default is Dockerfile (relative to context path) - # file: Containerfile - - ### build stage to target, defaults to empty, which builds to last stage in Dockerfile - # target: - - ### platforms to build for, defaults to linux/amd64 - ### other options: linux/amd64,linux/arm64,linux/arm/v7 - # FIXME worker arm/v7 support doesn't build in .net core 3.1 with QEMU - # a fix would likely run the .net build on amd64 but with a target of arm/v7 - platforms: linux/amd64,linux/arm64,linux/arm/v7 + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push worker image + uses: docker/build-push-action@v5 + with: + context: ./worker + push: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: | + paranormalrave/shedeploys-worker:latest + ghcr.io/scagroup6/example-voting-app-worker:latest ### Create a PR comment with image tags and labels ### defaults to false diff --git a/docker-compose.yml b/docker-compose.yml index 5915ffd741..d647256ecf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,13 @@ -# version is now using "compose spec" -# v2 and v3 are now combined! -# docker-compose v1.27+ required - services: - vote: + vote: # frontend voting service build: context: ./vote target: dev + restart: always depends_on: redis: condition: service_healthy - healthcheck: + healthcheck: test: ["CMD", "curl", "-f", "http://localhost"] interval: 15s timeout: 5s @@ -23,14 +20,17 @@ services: networks: - front-tier - back-tier + environment: + - ENV=development result: - build: ./result + build: ./result # backend voting service # use nodemon rather than node for local dev + restart: always entrypoint: nodemon --inspect=0.0.0.0 server.js depends_on: db: - condition: service_healthy + condition: service_healthy volumes: - ./result:/usr/local/app ports: @@ -38,21 +38,25 @@ services: - "127.0.0.1:9229:9229" networks: - front-tier - - back-tier + - back-tier + environment: + - NODE_ENV=development - worker: + worker: # Background worker processing votes build: context: ./worker + restart: always depends_on: redis: - condition: service_healthy + condition: service_healthy db: - condition: service_healthy + condition: service_healthy networks: - back-tier redis: image: redis:alpine + restart: always volumes: - "./healthchecks:/healthchecks" healthcheck: @@ -63,9 +67,10 @@ services: db: image: postgres:15-alpine + restart: always environment: - POSTGRES_USER: "postgres" - POSTGRES_PASSWORD: "postgres" + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - "db-data:/var/lib/postgresql/data" - "./healthchecks:/healthchecks" @@ -75,22 +80,52 @@ services: networks: - back-tier - # this service runs once to seed the database with votes - # it won't run unless you specify the "seed" profile - # docker compose --profile seed up -d seed: build: ./seed-data profiles: ["seed"] depends_on: vote: - condition: service_healthy + condition: service_healthy networks: - front-tier restart: "no" + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - front-tier + - back-tier + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + depends_on: + - prometheus + networks: + - front-tier + - back-tier + + blackbox: + image: prom/blackbox-exporter:latest + container_name: blackbox + ports: + - "9115:9115" + networks: + - front-tier + - back-tier + volumes: db-data: networks: front-tier: - back-tier: + back-tier: \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000000..d93aec67ca --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,24 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'vote-app' + metrics_path: /metrics + static_configs: + - targets: ['vote:80'] + + - job_name: 'blackbox' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - http://vote:80 + - http://result:80 + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox:9115 \ No newline at end of file diff --git a/result/server.js b/result/server.js index 1c8593e7ee..d1ac0c5460 100644 --- a/result/server.js +++ b/result/server.js @@ -1,7 +1,89 @@ +// var express = require('express'), +// async = require('async'), +// { Pool } = require('pg'), +// cookieParser = require('cookie-parser'), +// app = express(), +// server = require('http').Server(app), +// io = require('socket.io')(server); + +// var port = process.env.PORT || 4000; + +// io.on('connection', function (socket) { + +// socket.emit('message', { text : 'Welcome!' }); + +// socket.on('subscribe', function (data) { +// socket.join(data.channel); +// }); +// }); + +// var pool = new Pool({ +// connectionString: 'postgres://postgres:postgres@db/postgres' +// }); + +// async.retry( +// {times: 1000, interval: 1000}, +// function(callback) { +// pool.connect(function(err, client, done) { +// if (err) { +// console.error("Waiting for db"); +// } +// callback(err, client); +// }); +// }, +// function(err, client) { +// if (err) { +// return console.error("Giving up"); +// } +// console.log("Connected to db"); +// getVotes(client); +// } +// ); + +// function getVotes(client) { +// client.query('SELECT vote, COUNT(id) AS count FROM votes GROUP BY vote', [], function(err, result) { +// if (err) { +// console.error("Error performing query: " + err); +// } else { +// var votes = collectVotesFromResult(result); +// io.sockets.emit("scores", JSON.stringify(votes)); +// } + +// setTimeout(function() {getVotes(client) }, 1000); +// }); +// } + +// function collectVotesFromResult(result) { +// var votes = {a: 0, b: 0}; + +// result.rows.forEach(function (row) { +// votes[row.vote] = parseInt(row.count); +// }); + +// return votes; +// } + +// app.use(cookieParser()); +// app.use(express.urlencoded()); +// app.use(express.static(__dirname + '/views')); + +// app.get('/', function (req, res) { +// res.sendFile(path.resolve(__dirname + '/views/index.html')); +// }); + +// server.listen(port, function () { +// var port = server.address().port; +// console.log('App running on port ' + port); +// }); + + + + var express = require('express'), async = require('async'), { Pool } = require('pg'), cookieParser = require('cookie-parser'), + path = require('path'), app = express(), server = require('http').Server(app), io = require('socket.io')(server); @@ -9,29 +91,27 @@ var express = require('express'), var port = process.env.PORT || 4000; io.on('connection', function (socket) { - - socket.emit('message', { text : 'Welcome!' }); - + socket.emit('message', { text: 'Welcome!' }); socket.on('subscribe', function (data) { socket.join(data.channel); }); }); var pool = new Pool({ - connectionString: 'postgres://postgres:postgres@db/postgres' + connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@db/postgres' }); async.retry( - {times: 1000, interval: 1000}, - function(callback) { - pool.connect(function(err, client, done) { + { times: 1000, interval: 1000 }, + function (callback) { + pool.connect(function (err, client, done) { if (err) { console.error("Waiting for db"); } callback(err, client); }); }, - function(err, client) { + function (err, client) { if (err) { return console.error("Giving up"); } @@ -41,20 +121,37 @@ async.retry( ); function getVotes(client) { - client.query('SELECT vote, COUNT(id) AS count FROM votes GROUP BY vote', [], function(err, result) { - if (err) { - console.error("Error performing query: " + err); - } else { - var votes = collectVotesFromResult(result); - io.sockets.emit("scores", JSON.stringify(votes)); - } + // get scores for a, b, c + client.query( + 'SELECT vote, COUNT(id) AS count FROM votes GROUP BY vote', + [], + function (err, result) { + if (err) { + console.error("Error performing query: " + err); + } else { + var votes = collectVotesFromResult(result); - setTimeout(function() {getVotes(client) }, 1000); - }); + // get 10 most recent votes with names and timestamps + client.query( + 'SELECT id, vote, voted_at FROM votes ORDER BY voted_at DESC LIMIT 10', + [], + function (err2, recentResult) { + if (err2) { + console.error("Error fetching recent votes: " + err2); + } else { + votes.recent = collectRecentVotes(recentResult); + } + io.sockets.emit("scores", JSON.stringify(votes)); + } + ); + } + setTimeout(function () { getVotes(client); }, 1000); + } + ); } function collectVotesFromResult(result) { - var votes = {a: 0, b: 0}; + var votes = { a: 0, b: 0, c: 0 }; result.rows.forEach(function (row) { votes[row.vote] = parseInt(row.count); @@ -63,15 +160,55 @@ function collectVotesFromResult(result) { return votes; } +function collectRecentVotes(result) { + return result.rows.map(function (row) { + // id column stores the voter name from app.py + var name = row.id || 'Anonymous'; + var initials = name + .split(' ') + .map(function (w) { return w.charAt(0).toUpperCase(); }) + .join('') + .slice(0, 2); + + return { + voter_name: name, + initials: initials, + vote: row.vote, + voted_at: row.voted_at + ? new Date(row.voted_at).toLocaleString('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + : '' + }; + }); +} + app.use(cookieParser()); -app.use(express.urlencoded()); +app.use(express.urlencoded({ extended: false })); app.use(express.static(__dirname + '/views')); app.get('/', function (req, res) { res.sendFile(path.resolve(__dirname + '/views/index.html')); }); +// ── Reset endpoint ── +app.post('/reset', function (req, res) { + pool.query('DELETE FROM votes', function (err) { + if (err) { + console.error('Reset failed:', err); + return res.status(500).json({ error: 'Reset failed' }); + } + console.log('Votes reset at', new Date().toISOString()); + res.json({ status: 'ok', message: 'All votes cleared' }); + }); +}); + server.listen(port, function () { - var port = server.address().port; console.log('App running on port ' + port); -}); +}); \ No newline at end of file diff --git a/result/views/app.js b/result/views/app.js index fe8b60044b..546e6ff343 100644 --- a/result/views/app.js +++ b/result/views/app.js @@ -1,50 +1,52 @@ var app = angular.module('catsvsdogs', []); var socket = io.connect(); -var bg1 = document.getElementById('background-stats-1'); -var bg2 = document.getElementById('background-stats-2'); - -app.controller('statsCtrl', function($scope){ - $scope.aPercent = 50; - $scope.bPercent = 50; - - var updateScores = function(){ - socket.on('scores', function (json) { - data = JSON.parse(json); - var a = parseInt(data.a || 0); - var b = parseInt(data.b || 0); - - var percentages = getPercentages(a, b); - - bg1.style.width = percentages.a + "%"; - bg2.style.width = percentages.b + "%"; +app.controller('statsCtrl', function ($scope) { + + // default values before first socket message + $scope.aVotes = 0; + $scope.bVotes = 0; + $scope.cVotes = 0; + $scope.total = 0; + $scope.aPercent = 0; + $scope.bPercent = 0; + $scope.cPercent = 0; + $scope.recentVotes = []; + + socket.on('message', function () { + document.body.style.opacity = 1; + }); - $scope.$apply(function () { - $scope.aPercent = percentages.a; - $scope.bPercent = percentages.b; - $scope.total = a + b; - }); + socket.on('scores', function (json) { + var data = JSON.parse(json); + + var a = parseInt(data.a || 0); + var b = parseInt(data.b || 0); + var c = parseInt(data.c || 0); + var total = a + b + c; + + var percentages = getPercentages(a, b, c, total); + + $scope.$apply(function () { + $scope.aVotes = a; + $scope.bVotes = b; + $scope.cVotes = c; + $scope.total = total; + $scope.aPercent = percentages.a; + $scope.bPercent = percentages.b; + $scope.cPercent = percentages.c; + $scope.recentVotes = data.recent || []; }); - }; - - var init = function(){ - document.body.style.opacity=1; - updateScores(); - }; - socket.on('message',function(data){ - init(); }); -}); -function getPercentages(a, b) { - var result = {}; +}); - if (a + b > 0) { - result.a = Math.round(a / (a + b) * 100); - result.b = 100 - result.a; - } else { - result.a = result.b = 50; +function getPercentages(a, b, c, total) { + if (total === 0) { + return { a: 0, b: 0, c: 0 }; } - - return result; + var aP = Math.round(a / total * 100); + var bP = Math.round(b / total * 100); + var cP = 100 - aP - bP; + return { a: aP, b: bP, c: cP }; } \ No newline at end of file diff --git a/result/views/index.html b/result/views/index.html index 4427ffa1af..1695cce53c 100644 --- a/result/views/index.html +++ b/result/views/index.html @@ -1,43 +1,209 @@ - + -
- -release/v1.0.0-prod · Build #001
++ This will permanently clear all votes. Enter the admin password to continue. +
+Incorrect password. Please try again.
+All votes cleared successfully.
+ - - - - - +