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 @@ - + - - - Cats vs Dogs -- Result - - - - - - - -
-
-
-
-
-
-
-
-
-
Cats
-
{{aPercent | number:1}}%
-
-
-
-
Dogs
-
{{bPercent | number:1}}%
-
+ + + + SheDeploys — Release Vote Tally + + + + + + +
+ +
+
+ + + + SHEDEPLOYS + / + RELEASE APPROVAL BOARD +
+
v1.0.0
+
+ + +
+
+

Release v1.0.0 — Vote Tally

+

release/v1.0.0-prod  ·  Build #001

+
+
Live
+
+ + +
+
+
+ Approve + {{aVotes}} {{aVotes === 1 ? 'vote' : 'votes'}} +
+
+
+
+ {{aPercent | number:0}}% +
+ +
+
+ Needs Testing + {{bVotes}} {{bVotes === 1 ? 'vote' : 'votes'}} +
+
+
+
+ {{bPercent | number:0}}% +
+ +
+
+ Reject + {{cVotes}} {{cVotes === 1 ? 'vote' : 'votes'}}
+
+
+
+ {{cPercent | number:0}}% +
+
+ + +
+
+
{{aVotes}}
+
Approved
+
+
+
{{bVotes}}
+
Needs testing
+
+
+
{{cVotes}}
+
Rejected
-
- No votes yet - {{total}} vote - {{total}} votes + + +
+
RECENT VOTES WITH TIMESTAMPS
+ +
No votes yet
+ +
+
{{v.initials}}
+
{{v.voter_name}}
+
+ {{v.vote == 'a' ? 'Approve' : v.vote == 'b' ? 'Needs Testing' : + 'Reject'}} +
+
{{v.voted_at}}
+
+
+
+ + +
+ +
+ +
+ + +
+
+
+

Admin — Reset votes

+ +
+

+ This will permanently clear all votes. Enter the admin password to continue. +

+
+ + +
+ + +
+
- - - - - +
+ + + + + + + + + \ No newline at end of file diff --git a/result/views/stylesheets/style.css b/result/views/stylesheets/style.css index 6842773ebb..c0cd6a81ce 100644 --- a/result/views/stylesheets/style.css +++ b/result/views/stylesheets/style.css @@ -1,112 +1,624 @@ @import url(//fonts.googleapis.com/css?family=Open+Sans:400,700,600); -*{ - box-sizing:border-box; +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; } -html,body{ - margin:0; - padding:0; - height:100%; - font-family: 'Open Sans'; + +html, +body { + height: 100%; } -body{ - opacity:0; - transition: all 1s linear; + +body { + background: #0d1117; + color: #e6edf3; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Open Sans", sans-serif; + min-height: 100vh; + padding-bottom: 60px; + opacity: 0; + transition: opacity 0.5s ease; } -.divider{ - height: 150px; - width:2px; - background-color: #C0C9CE; - position: relative; - top: 50%; - float: left; - transform: translateY(-50%); +#app { + max-width: 1280px; + margin: 0 auto; + padding: 0 16px; } -#background-stats-1{ - background-color: #2196f3; +/* ── Top bar ── */ +#topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 0 28px; + border-bottom: 1px solid #21262d; + margin-bottom: 36px; } -#background-stats-2{ - background-color: #00cbca; +#topbar-left { + display: flex; + align-items: center; + gap: 8px; } -#content-container{ - z-index:2; - position:relative; - margin:0 auto; - display:table; - padding:10px; - max-width:940px; - height:100%; +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; } -#content-container-center{ - display:table-cell; - text-align:center; - vertical-align:middle; + +.dot.red { + background: #ff5f57; } -#result{ - z-index: 3; - position: absolute; - bottom: 40px; - right: 20px; - color: #fff; - opacity: 0.5; - font-size: 45px; + +.dot.amber { + background: #febc2e; +} + +.dot.green { + background: #28c840; +} + +#brand { + font-size: 13px; + font-weight: 700; + letter-spacing: 2px; + color: #e6edf3; + margin-left: 8px; +} + +#brand-sep { + color: #484f58; + font-size: 13px; +} + +#brand-sub { + font-size: 12px; + letter-spacing: 1.5px; + color: #8b949e; +} + +#version-badge { + font-size: 12px; + border: 1px solid #30363d; + border-radius: 20px; + padding: 4px 12px; + color: #8b949e; + display: none; +} + +/* ── Heading ── */ +#heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 32px; + flex-wrap: wrap; + gap: 12px; +} + +#heading h1 { + font-size: 1.5rem; + font-weight: 700; + color: #e6edf3; + margin-bottom: 6px; +} + +.subtext { + font-size: 0.85rem; + color: #8b949e; +} + +#live-badge { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: #3fb950; + background: #0f2a0f; + border: 1px solid #2d6a2d; + border-radius: 20px; + padding: 5px 14px; + white-space: nowrap; +} + +.live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #3fb950; + display: inline-block; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +/* ── Progress bars ── */ +#progress-section { + background: #161b22; + border: 1px solid #21262d; + border-radius: 10px; + padding: 24px; + margin-bottom: 20px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.progress-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.progress-meta { + display: flex; + justify-content: space-between; + align-items: center; +} + +.progress-label { + font-size: 0.9rem; + font-weight: 500; + color: #e6edf3; +} + +.progress-count { + font-size: 0.82rem; font-weight: 600; } -#choice{ - transition: all 300ms linear; - line-height:1.3em; - background:#fff; - box-shadow: 10px 0 0 #fff, -10px 0 0 #fff; - vertical-align:middle; - font-size:40px; + +.progress-track { + width: 100%; + height: 8px; + background: #21262d; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 4px; + transition: width 400ms ease-in-out; + min-width: 0%; +} + +.progress-pct { + font-size: 0.8rem; + color: #8b949e; + align-self: flex-end; +} + +/* ── Colour utilities ── */ +.green-text { + color: #3fb950; +} + +.amber-text { + color: #e3b341; +} + +.red-text { + color: #f85149; +} + +.green-fill { + background: #3fb950; +} + +.amber-fill { + background: #e3b341; +} + +.red-fill { + background: #f85149; +} + +/* ── Count cards ── */ +#count-cards { + display: flex; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.count-card { + flex: 1; + min-width: 140px; + background: #161b22; + border: 1px solid #21262d; + border-radius: 10px; + padding: 24px; + text-align: center; +} + +.count-number { + font-size: 2.4rem; + font-weight: 700; + margin-bottom: 6px; +} + +.count-label { + font-size: 0.82rem; + color: #8b949e; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* ── Recent votes feed ── */ +#recent-votes { + background: #161b22; + border: 1px solid #21262d; + border-radius: 10px; + overflow: hidden; +} + +#recent-header { + font-size: 0.72rem; + letter-spacing: 2px; + color: #8b949e; + padding: 16px 24px; + border-bottom: 1px solid #21262d; +} + +#no-votes { + padding: 32px 24px; + color: #484f58; + font-size: 0.9rem; + text-align: center; +} + +.vote-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 24px; + border-bottom: 1px solid #21262d; + transition: background 0.15s; +} + +.vote-row:last-child { + border-bottom: none; +} + +.vote-row:hover { + background: #1c2128; +} + +.voter-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: #21262d; + color: #8b949e; + font-size: 0.72rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + letter-spacing: 0.5px; +} + +.voter-name { + flex: 1; + font-size: 0.88rem; + color: #e6edf3; +} + +.voter-choice { + font-size: 0.82rem; font-weight: 600; - width: 450px; - height: 200px; } -#choice a{ - text-decoration:none; + +.voter-time { + font-size: 0.78rem; + color: #484f58; + white-space: nowrap; +} + +/* ── Reset section ── */ +#reset-section { + display: flex; + justify-content: center; + padding: 48px 0 24px; +} + +#reset-btn { + background: transparent; + border: 1px solid #30363d; + border-radius: 6px; + color: #484f58; + font-size: 0.82rem; + padding: 8px 20px; + cursor: pointer; + transition: + border-color 0.15s, + color 0.15s; +} + +#reset-btn:hover { + border-color: #f85149; + color: #f85149; +} + +/* ── Reset modal overlay ── */ +#reset-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 100; } -#choice a:hover, #choice a:focus{ - outline:0; - text-decoration:underline; + +#reset-overlay.active { + display: block; } -#choice .choice{ - width: 49%; - position: relative; +/* ── Reset modal ── */ +#reset-modal { + display: none; + position: fixed; top: 50%; - transform: translateY(-50%); - text-align: left; - padding-left: 50px; + left: 50%; + transform: translate(-50%, -50%); + z-index: 200; + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 32px; + width: 90%; + max-width: 420px; } -#choice .choice .label{ - text-transform: uppercase; +#reset-modal.active { + display: block; } -#choice .choice.dogs{ - color: #00cbca; - float: right; +.hidden { + display: none; } -#choice .choice.cats{ - color: #2196f3; - float: left; +/* ── Shake animation ── */ +@keyframes shake { + 0% { + transform: translate(-50%, -50%); + } + + 15% { + transform: translate(-46%, -50%); + } + + 30% { + transform: translate(-54%, -50%); + } + + 45% { + transform: translate(-46%, -50%); + } + + 60% { + transform: translate(-54%, -50%); + } + + 75% { + transform: translate(-48%, -50%); + } + + 100% { + transform: translate(-50%, -50%); + } +} + +#reset-modal.shake { + animation: shake 0.5s ease; } -#background-stats{ - z-index:1; - height:100%; - width:100%; - position:absolute; + +/* ── Reset modal header ── */ +#reset-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; } -#background-stats div{ - transition: width 400ms ease-in-out; - display:inline-block; - margin-bottom:-4px; - width:50%; - height:100%; + +#reset-modal-title { + font-size: 1rem; + font-weight: 600; + color: #e6edf3; +} + +#reset-close-btn { + background: transparent; + border: none; + color: #8b949e; + font-size: 1rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: + background 0.15s, + color 0.15s; +} + +#reset-close-btn:hover { + background: #21262d; + color: #e6edf3; +} + +/* ── Reset modal body ── */ +#reset-modal-desc { + font-size: 0.85rem; + color: #8b949e; + margin-bottom: 20px; + line-height: 1.6; +} + +#reset-error { + font-size: 0.8rem; + color: #f85149; + margin-top: 8px; + margin-bottom: 4px; +} + +#reset-success { + font-size: 0.8rem; + color: #3fb950; + margin-top: 8px; + margin-bottom: 4px; +} + +/* ── Reset modal buttons ── */ +.field-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.field-group label { + font-size: 0.8rem; + color: #8b949e; +} + +.field-group input { + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + padding: 10px 12px; + color: #e6edf3; + font-size: 0.9rem; + outline: none; + transition: border-color 0.15s; +} + +.field-group input:focus { + border-color: #58a6ff; +} + +.field-group input::placeholder { + color: #484f58; +} + +#reset-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; + justify-content: flex-end; +} + +#reset-cancel-btn, +#reset-confirm-btn { + padding: 9px 20px; + border-radius: 6px; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: + opacity 0.15s, + background 0.15s; +} + +#reset-confirm-btn { + background: #6a1f1f; + border-color: #f85149; + color: #fff; +} + +#reset-confirm-btn:hover { + opacity: 0.85; +} + +#reset-confirm-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Responsive ── */ +@media (min-width: 768px) { + #app { + padding: 0 32px; + } + + #version-badge { + display: block; + } + + #topbar { + padding: 24px 0 32px; + } + + #heading h1 { + font-size: 1.7rem; + } + + .count-card { + min-width: unset; + } + + #count-cards { + flex-wrap: nowrap; + } + + .progress-row { + display: grid; + grid-template-columns: 160px 1fr 40px; + grid-template-rows: auto auto; + align-items: center; + gap: 8px 16px; + } + + .progress-meta { + grid-column: 1; + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .progress-track { + grid-column: 2; + grid-row: 1 / 3; + align-self: center; + } + + .progress-pct { + grid-column: 3; + grid-row: 1 / 3; + align-self: center; + text-align: right; + } +} + +@media (min-width: 1024px) { + #app { + padding: 0 48px; + } + + #topbar { + padding: 24px 0 36px; + } + + #heading { + margin-bottom: 36px; + } + + #heading h1 { + font-size: 1.9rem; + } } diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..92a2bcccd8 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/terraform/alb.tf b/terraform/alb.tf new file mode 100644 index 0000000000..038c7ca32c --- /dev/null +++ b/terraform/alb.tf @@ -0,0 +1,76 @@ +# ------------------------------------------------------- +# Application Load Balancer +# ------------------------------------------------------- +resource "aws_lb" "main" { + name = "${var.project_name}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + + tags = { Name = "${var.project_name}-alb" } +} + +# ------------------------------------------------------- +# Target Groups +# ------------------------------------------------------- +resource "aws_lb_target_group" "vote" { + name = "${var.project_name}-vote-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + health_check { + path = "/" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + } + + tags = { Name = "${var.project_name}-vote-tg" } +} + +resource "aws_lb_target_group" "result" { + name = "${var.project_name}-result-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + health_check { + path = "/" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + } + + tags = { Name = "${var.project_name}-result-tg" } +} + +# ------------------------------------------------------- +# Listeners +# vote app → port 80 (default) +# result app → port 8080 (separate listener) +# ------------------------------------------------------- +resource "aws_lb_listener" "vote" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.vote.arn + } +} + +resource "aws_lb_listener" "result" { + load_balancer_arn = aws_lb.main.arn + port = 8080 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.result.arn + } +} diff --git a/terraform/cloudwatch.tf b/terraform/cloudwatch.tf new file mode 100644 index 0000000000..e41547705e --- /dev/null +++ b/terraform/cloudwatch.tf @@ -0,0 +1,81 @@ +# ------------------------------------------------------- +# CloudWatch Alarms for monitoring +# ------------------------------------------------------- + +# Alert when vote service CPU goes above 80% +resource "aws_cloudwatch_metric_alarm" "vote_cpu_high" { + alarm_name = "${var.project_name}-vote-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 60 + statistic = "Average" + threshold = 80 + alarm_description = "Vote service CPU utilization is too high" + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.vote.name + } + + tags = { Name = "${var.project_name}-vote-cpu-alarm" } +} + +# Alert when result service CPU goes above 80% +resource "aws_cloudwatch_metric_alarm" "result_cpu_high" { + alarm_name = "${var.project_name}-result-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 60 + statistic = "Average" + threshold = 80 + alarm_description = "Result service CPU utilization is too high" + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.result.name + } + + tags = { Name = "${var.project_name}-result-cpu-alarm" } +} + +# Alert when RDS has too many connections +resource "aws_cloudwatch_metric_alarm" "rds_connections_high" { + alarm_name = "${var.project_name}-rds-connections-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseConnections" + namespace = "AWS/RDS" + period = 60 + statistic = "Average" + threshold = 50 + alarm_description = "RDS connection count is too high" + + dimensions = { + DBInstanceIdentifier = aws_db_instance.postgres.identifier + } + + tags = { Name = "${var.project_name}-rds-connections-alarm" } +} + +# Alert when ElastiCache CPU goes above 75% +resource "aws_cloudwatch_metric_alarm" "redis_cpu_high" { + alarm_name = "${var.project_name}-redis-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ElastiCache" + period = 60 + statistic = "Average" + threshold = 75 + alarm_description = "Redis CPU utilization is too high" + + dimensions = { + CacheClusterId = aws_elasticache_cluster.redis.cluster_id + } + + tags = { Name = "${var.project_name}-redis-cpu-alarm" } +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000000..fbc1fe6ea5 --- /dev/null +++ b/terraform/ecs.tf @@ -0,0 +1,188 @@ +# ------------------------------------------------------- +# ECS Cluster +# ------------------------------------------------------- +resource "aws_ecs_cluster" "main" { + name = "${var.project_name}-cluster" + tags = { Name = "${var.project_name}-cluster" } +} + +# ------------------------------------------------------- +# CloudWatch Log Group +# ------------------------------------------------------- +resource "aws_cloudwatch_log_group" "voting_app" { + name = "/ecs/${var.project_name}" + retention_in_days = 7 +} + +# ------------------------------------------------------- +# Task Definitions +# ------------------------------------------------------- + +# --- Vote --- +resource "aws_ecs_task_definition" "vote" { + family = "${var.project_name}-vote" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "256" + memory = "512" + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "vote" + image = var.vote_image + essential = true + + portMappings = [{ + containerPort = 80 + protocol = "tcp" + }] + + environment = [ + { name = "REDIS_HOST", value = aws_elasticache_cluster.redis.cache_nodes[0].address } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.voting_app.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "vote" + } + } + }]) +} + +# --- Result --- +resource "aws_ecs_task_definition" "result" { + family = "${var.project_name}-result" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "256" + memory = "512" + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "result" + image = var.result_image + essential = true + + portMappings = [{ + containerPort = 80 + protocol = "tcp" + }] + + environment = [ + { name = "DATABASE_URL", value = "postgres://${var.db_username}:${var.db_password}@${aws_db_instance.postgres.address}/${var.db_name}" } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.voting_app.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "result" + } + } + }]) +} + +# --- Worker --- +resource "aws_ecs_task_definition" "worker" { + family = "${var.project_name}-worker" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "256" + memory = "512" + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "worker" + image = var.worker_image + essential = true + + environment = [ + { name = "REDIS_HOST", value = aws_elasticache_cluster.redis.cache_nodes[0].address }, + { name = "DATABASE_HOST", value = aws_db_instance.postgres.address }, + { name = "DATABASE_USER", value = var.db_username }, + { name = "DATABASE_PASSWORD", value = var.db_password }, + { name = "DATABASE_NAME", value = var.db_name } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.voting_app.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "worker" + } + } + }]) +} + +# ------------------------------------------------------- +# ECS Services +# ------------------------------------------------------- + +# --- Vote Service --- +resource "aws_ecs_service" "vote" { + name = "${var.project_name}-vote" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.vote.arn + desired_count = 2 + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.vote.arn + container_name = "vote" + container_port = 80 + } + + depends_on = [aws_lb_listener.vote] +} + +# --- Result Service --- +resource "aws_ecs_service" "result" { + name = "${var.project_name}-result" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.result.arn + desired_count = 2 + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.result.arn + container_name = "result" + container_port = 80 + } + + depends_on = [aws_lb_listener.result] +} + +# --- Worker Service (no ALB needed, background processor) --- +resource "aws_ecs_service" "worker" { + name = "${var.project_name}-worker" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.worker.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } +} diff --git a/terraform/elasticache.tf b/terraform/elasticache.tf new file mode 100644 index 0000000000..03b3ade8e0 --- /dev/null +++ b/terraform/elasticache.tf @@ -0,0 +1,24 @@ +# ------------------------------------------------------- +# ElastiCache — replaces local Redis container +# ------------------------------------------------------- + +resource "aws_elasticache_subnet_group" "redis" { + name = "${var.project_name}-redis-subnet-group" + subnet_ids = aws_subnet.private[*].id + + tags = { Name = "${var.project_name}-redis-subnet-group" } +} + +resource "aws_elasticache_cluster" "redis" { + cluster_id = "${var.project_name}-redis" + engine = "redis" + node_type = var.redis_node_type + num_cache_nodes = 1 + parameter_group_name = "default.redis7" + engine_version = "7.0" + port = 6379 + subnet_group_name = aws_elasticache_subnet_group.redis.name + security_group_ids = [aws_security_group.redis.id] + + tags = { Name = "${var.project_name}-redis" } +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000000..382c95f1a1 --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,60 @@ +# ------------------------------------------------------- +# ECS Task Execution Role +# Allows ECS to pull images and write logs to CloudWatch +# ------------------------------------------------------- +resource "aws_iam_role" "ecs_task_execution" { + name = "${var.project_name}-ecs-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = { Name = "${var.project_name}-ecs-execution-role" } +} + +resource "aws_iam_role_policy_attachment" "ecs_execution_policy" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# ------------------------------------------------------- +# ECS Task Role +# Permissions the running container itself has +# ------------------------------------------------------- +resource "aws_iam_role" "ecs_task" { + name = "${var.project_name}-ecs-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = { Name = "${var.project_name}-ecs-task-role" } +} + +# Allow tasks to write logs +resource "aws_iam_role_policy" "ecs_task_logs" { + name = "${var.project_name}-ecs-task-logs" + role = aws_iam_role.ecs_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "*" + }] + }) +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..b467ea11a0 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..6d10e7c523 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,29 @@ +output "alb_dns_name" { + description = "DNS name of the load balancer" + value = aws_lb.main.dns_name +} + +output "vote_url" { + description = "URL to access the vote app" + value = "http://${aws_lb.main.dns_name}" +} + +output "result_url" { + description = "URL to access the result app" + value = "http://${aws_lb.main.dns_name}:8080" +} + +output "rds_endpoint" { + description = "RDS Postgres endpoint" + value = aws_db_instance.postgres.address +} + +output "redis_endpoint" { + description = "ElastiCache Redis endpoint" + value = aws_elasticache_cluster.redis.cache_nodes[0].address +} + +output "ecs_cluster_name" { + description = "ECS cluster name" + value = aws_ecs_cluster.main.name +} diff --git a/terraform/rds.tf b/terraform/rds.tf new file mode 100644 index 0000000000..6fdc2ba218 --- /dev/null +++ b/terraform/rds.tf @@ -0,0 +1,28 @@ +# ------------------------------------------------------- +# RDS — replaces local Postgres container +# ------------------------------------------------------- + +resource "aws_db_subnet_group" "postgres" { + name = "${var.project_name}-db-subnet-group" + subnet_ids = aws_subnet.private[*].id + + tags = { Name = "${var.project_name}-db-subnet-group" } +} + +resource "aws_db_instance" "postgres" { + identifier = "${var.project_name}-postgres" + engine = "postgres" + engine_version = "15" + instance_class = var.db_instance_class + allocated_storage = 20 + db_name = var.db_name + username = var.db_username + password = var.db_password + db_subnet_group_name = aws_db_subnet_group.postgres.name + vpc_security_group_ids = [aws_security_group.rds.id] + skip_final_snapshot = true + publicly_accessible = false + multi_az = false + + tags = { Name = "${var.project_name}-postgres" } +} diff --git a/terraform/security_groups.tf b/terraform/security_groups.tf new file mode 100644 index 0000000000..2c4322559c --- /dev/null +++ b/terraform/security_groups.tf @@ -0,0 +1,111 @@ +# ------------------------------------------------------- +# ALB Security Group — accepts HTTP from the internet +# ------------------------------------------------------- +resource "aws_security_group" "alb" { + name = "${var.project_name}-alb-sg" + description = "Allow HTTP inbound to ALB" + vpc_id = aws_vpc.main.id + + ingress { + description = "HTTP from internet" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Result app port from internet" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-alb-sg" } +} + +# ------------------------------------------------------- +# ECS Tasks Security Group — accepts traffic from ALB only +# ------------------------------------------------------- +resource "aws_security_group" "ecs_tasks" { + name = "${var.project_name}-ecs-tasks-sg" + description = "Allow inbound from ALB to ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "From ALB on port 80" + from_port = 80 + to_port = 80 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-ecs-tasks-sg" } +} + +# ------------------------------------------------------- +# RDS Security Group — accepts Postgres from ECS tasks only +# ------------------------------------------------------- +resource "aws_security_group" "rds" { + name = "${var.project_name}-rds-sg" + description = "Allow Postgres from ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "Postgres from ECS" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-rds-sg" } +} + +# ------------------------------------------------------- +# ElastiCache Security Group — accepts Redis from ECS tasks only +# ------------------------------------------------------- +resource "aws_security_group" "redis" { + name = "${var.project_name}-redis-sg" + description = "Allow Redis from ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "Redis from ECS" + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-redis-sg" } +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..692db2b430 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,87 @@ +variable "aws_region" { + description = "AWS region to deploy into" + type = string + default = "us-east-1" +} + +variable "project_name" { + description = "Project name used for naming resources" + type = string + default = "voting-app" +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "public_subnet_cidrs" { + description = "CIDR blocks for public subnets" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24"] +} + +variable "private_subnet_cidrs" { + description = "CIDR blocks for private subnets" + type = list(string) + default = ["10.0.3.0/24", "10.0.4.0/24"] +} + +variable "availability_zones" { + description = "Availability zones to use" + type = list(string) + default = ["us-east-1a", "us-east-1b"] +} + +# --- Container image URIs (push to ECR and update these) --- +variable "vote_image" { + description = "Docker image URI for the vote service" + type = string + default = "dockersamples/examplevotingapp_vote:latest" +} + +variable "result_image" { + description = "Docker image URI for the result service" + type = string + default = "dockersamples/examplevotingapp_result:latest" +} + +variable "worker_image" { + description = "Docker image URI for the worker service" + type = string + default = "dockersamples/examplevotingapp_worker:latest" +} + +# --- Database --- +variable "db_name" { + description = "Postgres database name" + type = string + default = "postgres" +} + +variable "db_username" { + description = "Postgres master username" + type = string + default = "postgres" +} + +variable "db_password" { + description = "Postgres master password" + type = string + sensitive = true + default = "changeme123!" +} + +variable "db_instance_class" { + description = "RDS instance class" + type = string + default = "db.t3.micro" +} + +# --- ElastiCache --- +variable "redis_node_type" { + description = "ElastiCache node type" + type = string + default = "cache.t3.micro" +} diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000000..d570a2cf4e --- /dev/null +++ b/terraform/vpc.tf @@ -0,0 +1,89 @@ +# ------------------------------------------------------- +# VPC +# ------------------------------------------------------- +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { Name = "${var.project_name}-vpc" } +} + +# ------------------------------------------------------- +# Subnets +# ------------------------------------------------------- +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + vpc_id = aws_vpc.main.id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = { Name = "${var.project_name}-public-${count.index + 1}" } +} + +resource "aws_subnet" "private" { + count = length(var.private_subnet_cidrs) + vpc_id = aws_vpc.main.id + cidr_block = var.private_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = { Name = "${var.project_name}-private-${count.index + 1}" } +} + +# ------------------------------------------------------- +# Internet Gateway + NAT Gateway +# ------------------------------------------------------- +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.main.id + tags = { Name = "${var.project_name}-igw" } +} + +resource "aws_eip" "nat" { + domain = "vpc" + tags = { Name = "${var.project_name}-nat-eip" } +} + +resource "aws_nat_gateway" "nat" { + allocation_id = aws_eip.nat.id + subnet_id = aws_subnet.public[0].id + tags = { Name = "${var.project_name}-nat" } + depends_on = [aws_internet_gateway.igw] +} + +# ------------------------------------------------------- +# Route Tables +# ------------------------------------------------------- +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id + } + + tags = { Name = "${var.project_name}-public-rt" } +} + +resource "aws_route_table_association" "public" { + count = length(aws_subnet.public) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.nat.id + } + + tags = { Name = "${var.project_name}-private-rt" } +} + +resource "aws_route_table_association" "private" { + count = length(aws_subnet.private) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private.id +} diff --git a/vote/app.py b/vote/app.py index 596546612a..80eed74030 100644 --- a/vote/app.py +++ b/vote/app.py @@ -1,13 +1,12 @@ -from flask import Flask, render_template, request, make_response, g +from flask import Flask, render_template, request, make_response, g, redirect, url_for from redis import Redis import os import socket import random import json import logging +from datetime import datetime, timezone -option_a = os.getenv('OPTION_A', "Cats") -option_b = os.getenv('OPTION_B', "Dogs") hostname = socket.gethostname() app = Flask(__name__) @@ -18,34 +17,70 @@ def get_redis(): if not hasattr(g, 'redis'): - g.redis = Redis(host="redis", db=0, socket_timeout=5) + redis_host = os.getenv('REDIS_HOST', 'redis') + g.redis = Redis(host=redis_host, db=0, socket_timeout=5) return g.redis -@app.route("/", methods=['POST','GET']) +@app.route("/", methods=['POST', 'GET']) def hello(): voter_id = request.cookies.get('voter_id') if not voter_id: voter_id = hex(random.getrandbits(64))[2:-1] vote = None + voter_name = '' if request.method == 'POST': redis = get_redis() - vote = request.form['vote'] - app.logger.info('Received vote for %s', vote) - data = json.dumps({'voter_id': voter_id, 'vote': vote}) - redis.rpush('votes', data) + vote = request.form.get('vote') + voter_name = request.form.get('voter_name', '').strip() + + if vote in ('a', 'b', 'c'): + if voter_name: + voter_id = voter_name + + voted_at = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + + data = json.dumps({ + 'voter_id': voter_id, + 'voter_name': voter_name, + 'vote': vote, + 'voted_at': voted_at + }) + + redis.rpush('votes', data) + app.logger.info('Received vote for %s from %s', vote, voter_name) + + resp = make_response(redirect(url_for('hello'))) + resp.set_cookie('voter_id', voter_id) + resp.set_cookie('voter_name', voter_name) + resp.set_cookie('has_voted', 'yes') + return resp + + has_voted = request.cookies.get('has_voted') + voter_name = request.cookies.get('voter_name', '') + + if has_voted: + vote = has_voted resp = make_response(render_template( 'index.html', - option_a=option_a, - option_b=option_b, - hostname=hostname, vote=vote, + hostname=hostname, + voter_name=voter_name )) resp.set_cookie('voter_id', voter_id) return resp +@app.route("/clear-cookie") +def clear_cookie(): + resp = make_response(redirect(url_for('hello'))) + resp.delete_cookie('voter_id') + resp.delete_cookie('voter_name') + resp.delete_cookie('has_voted') + return resp + + if __name__ == "__main__": - app.run(host='0.0.0.0', port=80, debug=True, threaded=True) + app.run(host='0.0.0.0', port=80, debug=True, threaded=True) \ No newline at end of file diff --git a/vote/static/stylesheets/style.css b/vote/static/stylesheets/style.css index 53de54388d..087319bac3 100644 --- a/vote/static/stylesheets/style.css +++ b/vote/static/stylesheets/style.css @@ -1,129 +1,429 @@ @import url(//fonts.googleapis.com/css?family=Open+Sans:400,700,600); -*{ - box-sizing:border-box; -} -html,body{ +*, +*::before, +*::after { + box-sizing: border-box; margin: 0; padding: 0; - background-color: #F7F8F9; - height: 100vh; - font-family: 'Open Sans'; } -button{ - border-radius: 0; - width: 100%; - height: 50%; +body { + background: #0d1117; + color: #e6edf3; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Open Sans", sans-serif; + min-height: 100vh; + padding-bottom: 60px; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 0 16px; +} + +/* ── Top bar ── */ +#topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 0 28px; + border-bottom: 1px solid #21262d; + margin-bottom: 36px; } -button[type="submit"] { - -webkit-appearance:none; -webkit-border-radius:0; +#topbar-left { + display: flex; + align-items: center; + gap: 8px; +} + +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} +.dot.red { + background: #ff5f57; +} +.dot.amber { + background: #febc2e; +} +.dot.green { + background: #28c840; } -button i{ - float: right; - padding-right: 30px; - margin-top: 3px; +#brand { + font-size: 13px; + font-weight: 700; + letter-spacing: 2px; + color: #e6edf3; + margin-left: 8px; +} +#brand-sep { + color: #484f58; + font-size: 13px; +} +#brand-sub { + font-size: 12px; + letter-spacing: 1.5px; + color: #8b949e; } -button.a{ - background-color: #1aaaf8; +#version-badge { + font-size: 12px; + border: 1px solid #30363d; + border-radius: 20px; + padding: 4px 12px; + color: #8b949e; + display: none; +} +/* ── Heading ── */ +#heading { + margin-bottom: 28px; +} +#heading h1 { + font-size: 1.9rem; + font-weight: 700; + color: #e6edf3; + margin-bottom: 8px; +} +.subtext { + font-size: 0.95rem; + color: #8b949e; + line-height: 1.6; } -button.b{ - background-color: #00cbca; +/* ── Release card ── */ +#release-card { + background: #161b22; + border: 1px solid #21262d; + border-radius: 10px; + padding: 18px 24px; + margin-bottom: 36px; + display: flex; + flex-direction: column; + gap: 10px; } -#tip{ - text-align: left; - color: #c0c9ce; - font-size: 14px; +#release-left { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; } -#hostname{ - position: absolute; - bottom: 100px; - right: 0; - left: 0; - color: #8f9ea8; - font-size: 24px; +#release-name { + font-size: 0.95rem; + font-weight: 600; + color: #e6edf3; } -#content-container{ - z-index: 2; - position: relative; - margin: 0 auto; - display: table; - padding: 10px; - max-width: 940px; - height: 100%; +#release-status { + font-size: 0.8rem; + color: #e3b341; + display: flex; + align-items: center; + gap: 5px; } -#content-container-center{ - display: table-cell; - text-align: center; + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #e3b341; + display: inline-block; } -#content-container-center h3{ - color: #254356; +#release-meta { + display: flex; + gap: 20px; + flex-wrap: wrap; } -#choice{ - transition: all 300ms linear; - line-height: 1.3em; - display: inline; - vertical-align: middle; - font-size: 3em; +.meta-item { + font-size: 0.8rem; + color: #8b949e; } -#choice a{ - text-decoration:none; +.meta-label { + color: #484f58; + margin-right: 4px; } -#choice a:hover, #choice a:focus{ - outline:0; - text-decoration:underline; + +/* ── Vote option cards ── */ +#vote-options { + display: flex; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; } -#choice button{ +.option-card { + flex: 1; + min-width: 200px; + border-radius: 10px; + padding: 28px 24px; + cursor: pointer; + border: 1px solid transparent; + transition: + transform 0.15s, + border-color 0.15s; +} + +.option-card:hover { + transform: translateY(-3px); +} + +.option-card.green { + background: #0f2a0f; + border-color: #2d6a2d; +} +.option-card.green:hover { + border-color: #3fb950; +} + +.option-card.amber { + background: #2a1f00; + border-color: #7a5c00; +} +.option-card.amber:hover { + border-color: #e3b341; +} + +.option-card.red { + background: #2a0f0f; + border-color: #6a2020; +} +.option-card.red:hover { + border-color: #f85149; +} + +.card-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 6px; + color: #e6edf3; +} +.card-sub { + font-size: 0.85rem; + color: #8b949e; +} + +#final-note { + font-size: 0.8rem; + color: #484f58; + text-align: center; + margin-top: 8px; +} + +/* ── Already voted ── */ +#voted-confirm { + text-align: center; + padding: 60px 20px; +} +.voted-icon { + font-size: 3rem; + color: #3fb950; + margin-bottom: 16px; +} +.voted-text { + font-size: 1.1rem; + color: #e6edf3; + margin-bottom: 8px; +} +.voted-sub { + font-size: 0.9rem; + color: #8b949e; +} + +/* ── Modal ── */ +#modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 100; +} +#modal-overlay.active { display: block; - height: 80px; - width: 330px; - border: none; - color: white; - text-transform: uppercase; - font-size:18px; - font-weight: 700; - margin-top: 10px; - margin-bottom: 10px; - text-align: left; - padding-left: 50px; } -#choice button.a:hover{ - background-color: #1488c6; +#modal { + display: none; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 200; + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 32px; + width: 90%; + max-width: 440px; +} +#modal.active { + display: block; +} + +#modal-title { + font-size: 1.1rem; + font-weight: 600; + color: #e6edf3; + margin-bottom: 16px; +} + +.modal-pill { + display: inline-block; + border-radius: 6px; + padding: 8px 16px; + font-size: 0.9rem; + font-weight: 600; + color: #e6edf3; + margin-bottom: 16px; +} +.modal-pill-sub { + font-weight: 400; + color: #c9d1d9; +} + +#modal-warning { + font-size: 0.82rem; + color: #8b949e; + margin-bottom: 20px; + line-height: 1.5; +} + +/* ── Name fields ── */ +.name-fields { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 8px; +} + +.field-group { + display: flex; + flex-direction: column; + flex: 1; + gap: 6px; +} + +.field-group label { + font-size: 0.8rem; + color: #8b949e; } -#choice button.b:hover{ - background-color: #00a2a1; +.field-group input { + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + padding: 10px 12px; + color: #e6edf3; + font-size: 0.9rem; + outline: none; + transition: border-color 0.15s; +} +.field-group input:focus { + border-color: #58a6ff; +} +.field-group input::placeholder { + color: #484f58; +} + +#name-error { + font-size: 0.8rem; + color: #f85149; + margin-bottom: 16px; +} +.hidden { + display: none; } -#choice button.a:focus{ - background-color: #1488c6; +/* ── Modal buttons ── */ +#modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; + justify-content: flex-end; } -#choice button.b:focus{ - background-color: #00a2a1; +#cancel-btn, +#confirm-btn { + padding: 9px 20px; + border-radius: 6px; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: opacity 0.15s; } -#background-stats{ - z-index:1; - height:100%; - width:100%; - position:absolute; +#cancel-btn { + background: transparent; + border-color: #30363d; + color: #8b949e; +} +#cancel-btn:hover { + background: #21262d; } -#background-stats div{ - transition: width 400ms ease-in-out; - display:inline-block; - margin-bottom:-4px; - width:50%; - height:100%; + +#confirm-btn { + background: #238636; + border-color: #2ea043; + color: #fff; +} +#confirm-btn:hover { + opacity: 0.85; +} + +/* ── Tablet (768px and up) ── */ +@media (min-width: 768px) { + #app { + padding: 0 32px; + } + + #topbar { + padding: 24px 0 32px; + } + + #version-badge { + display: block; + } + + #heading h1 { + font-size: 1.7rem; + } + + #vote-options { + flex-wrap: nowrap; + } + + .option-card { + min-width: unset; + } + + .name-fields { + flex-direction: row; + } +} + +/* ── Laptop (1024px and up) ── */ +@media (min-width: 1024px) { + #app { + padding: 0 48px; + } + + #topbar { + padding: 24px 0 36px; + } + + #heading h1 { + font-size: 1.9rem; + } + + #heading { + margin-bottom: 36px; + } } diff --git a/vote/templates/index.html b/vote/templates/index.html index fb58924dc7..8a2bfa52bc 100644 --- a/vote/templates/index.html +++ b/vote/templates/index.html @@ -1,49 +1,223 @@ - + - - {{option_a}} vs {{option_b}}! - - - - - - + + SheDeploys — Release Approval Board + + -
-
-

{{option_a}} vs {{option_b}}!

-
- - -
-
- (Tip: you can change your vote) -
-
- Processed by container ID {{hostname}} +
+ +
+
+ + + + SHEDEPLOYS + / + RELEASE APPROVAL BOARD
+
v1.0.0
+
+ + +
+

Should this release go to production?

+

+ Cast your vote — all team votes are required before deployment + proceeds. +

+
+ + +
+
+ release/v1.0.0-prod + + Awaiting approval + +
+
+ Branch main + Build #001 + Tests 42/42 passing + Initiated + +
+
+ + + {% if not vote %} +
+
+
Approve
+
Ready for prod
+
+ +
+
Needs Testing
+
Not confident yet
+
+ +
+
Reject
+
Do not deploy
+
+
+

+ Select one option — your vote is final and timestamped. +

+ + {% else %} + +
+
+

Your vote has been recorded.

+

+ Thank you, {{ voter_name }}. This vote is final. +

+
+ {% endif %} +
+ + + + - - - {% if vote %} + +
+ + +
+ - {% endif %} - + \ No newline at end of file diff --git a/worker/Program.cs b/worker/Program.cs index 9b5fb74d1a..310eb107d1 100644 --- a/worker/Program.cs +++ b/worker/Program.cs @@ -16,41 +16,50 @@ public static int Main(string[] args) { try { - var pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); - var redisConn = OpenRedisConnection("redis"); + var pgsql = OpenDbConnection( + $"Server={Environment.GetEnvironmentVariable("DATABASE_HOST") ?? "db"};" + + $"Username={Environment.GetEnvironmentVariable("DATABASE_USER") ?? "postgres"};" + + $"Password={Environment.GetEnvironmentVariable("DATABASE_PASSWORD") ?? "postgres"};" + ); + var redisConn = OpenRedisConnection( + Environment.GetEnvironmentVariable("REDIS_HOST") ?? "redis" + ); var redis = redisConn.GetDatabase(); - // Keep alive is not implemented in Npgsql yet. This workaround was recommended: - // https://github.com/npgsql/npgsql/issues/1214#issuecomment-235828359 var keepAliveCommand = pgsql.CreateCommand(); keepAliveCommand.CommandText = "SELECT 1"; - var definition = new { vote = "", voter_id = "" }; + // Added voter_name and voted_at to the definition + var definition = new { vote = "", voter_id = "", voter_name = "", voted_at = "" }; + while (true) { - // Slow down to prevent CPU spike, only query each 100ms Thread.Sleep(100); - // Reconnect redis if down if (redisConn == null || !redisConn.IsConnected) { Console.WriteLine("Reconnecting Redis"); redisConn = OpenRedisConnection("redis"); redis = redisConn.GetDatabase(); } + string json = redis.ListLeftPopAsync("votes").Result; if (json != null) { var vote = JsonConvert.DeserializeAnonymousType(json, definition); - Console.WriteLine($"Processing vote for '{vote.vote}' by '{vote.voter_id}'"); - // Reconnect DB if down + Console.WriteLine($"Processing vote for '{vote.vote}' by '{vote.voter_name}'"); + if (!pgsql.State.Equals(System.Data.ConnectionState.Open)) { Console.WriteLine("Reconnecting DB"); - pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); + pgsql = OpenDbConnection( + $"Server={Environment.GetEnvironmentVariable("DATABASE_HOST") ?? "db"};" + + $"Username={Environment.GetEnvironmentVariable("DATABASE_USER") ?? "postgres"};" + + $"Password={Environment.GetEnvironmentVariable("DATABASE_PASSWORD") ?? "postgres"};" + ); } else - { // Normal +1 vote requested - UpdateVote(pgsql, vote.voter_id, vote.vote); + { + UpdateVote(pgsql, vote.voter_id, vote.voter_name, vote.vote, vote.voted_at); } } else @@ -94,17 +103,24 @@ private static NpgsqlConnection OpenDbConnection(string connectionString) var command = connection.CreateCommand(); command.CommandText = @"CREATE TABLE IF NOT EXISTS votes ( - id VARCHAR(255) NOT NULL UNIQUE, - vote VARCHAR(255) NOT NULL - )"; - command.ExecuteNonQuery(); + id VARCHAR(255) NOT NULL UNIQUE, + voter_name VARCHAR(255) NOT NULL DEFAULT '', + vote VARCHAR(255) NOT NULL, + voted_at TIMESTAMPTZ NOT NULL DEFAULT now() + )"; +command.ExecuteNonQuery(); + +command.CommandText = @"ALTER TABLE votes ADD COLUMN IF NOT EXISTS voter_name VARCHAR(255) NOT NULL DEFAULT ''"; +command.ExecuteNonQuery(); + +command.CommandText = @"ALTER TABLE votes ADD COLUMN IF NOT EXISTS voted_at TIMESTAMPTZ NOT NULL DEFAULT now()"; +command.ExecuteNonQuery(); return connection; } private static ConnectionMultiplexer OpenRedisConnection(string hostname) { - // Use IP address to workaround https://github.com/StackExchange/StackExchange.Redis/issues/410 var ipAddress = GetIp(hostname); Console.WriteLine($"Found redis at {ipAddress}"); @@ -130,19 +146,34 @@ private static string GetIp(string hostname) .First(a => a.AddressFamily == AddressFamily.InterNetwork) .ToString(); - private static void UpdateVote(NpgsqlConnection connection, string voterId, string vote) + private static void UpdateVote( + NpgsqlConnection connection, + string voterId, + string voterName, + string vote, + string votedAt) { var command = connection.CreateCommand(); try { - command.CommandText = "INSERT INTO votes (id, vote) VALUES (@id, @vote)"; - command.Parameters.AddWithValue("@id", voterId); - command.Parameters.AddWithValue("@vote", vote); + command.CommandText = @"INSERT INTO votes (id, voter_name, vote, voted_at) + VALUES (@id, @voter_name, @vote, @voted_at)"; + command.Parameters.AddWithValue("@id", voterId); + command.Parameters.AddWithValue("@voter_name", voterName); + command.Parameters.AddWithValue("@vote", vote); + command.Parameters.AddWithValue("@voted_at", + string.IsNullOrEmpty(votedAt) + ? (object)DateTime.UtcNow + : DateTime.Parse(votedAt).ToUniversalTime()); command.ExecuteNonQuery(); } catch (DbException) { - command.CommandText = "UPDATE votes SET vote = @vote WHERE id = @id"; + command.CommandText = @"UPDATE votes + SET vote = @vote, + voter_name = @voter_name, + voted_at = @voted_at + WHERE id = @id"; command.ExecuteNonQuery(); } finally