{"id":35240310,"url":"https://github.com/cicd-tutorials/feedback","last_synced_at":"2026-04-09T03:31:33.648Z","repository":{"id":91355109,"uuid":"565301334","full_name":"cicd-tutorials/feedback","owner":"cicd-tutorials","description":"Example application using three-tier architecture.","archived":false,"fork":false,"pushed_at":"2025-07-16T19:17:37.000Z","size":207,"stargazers_count":0,"open_issues_count":5,"forks_count":3,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-08-02T11:52:41.637Z","etag":null,"topics":["django","docker-compose","example","example-application","svelte"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cicd-tutorials.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2022-11-13T00:21:55.000Z","updated_at":"2025-07-16T14:06:45.000Z","dependencies_parsed_at":"2024-05-21T17:33:11.754Z","dependency_job_id":"5a7e3c4f-5a89-4446-8094-11c1a6ea694f","html_url":"https://github.com/cicd-tutorials/feedback","commit_stats":null,"previous_names":["cicd-tutorials/feedback","kangasta/three-tier-example-app"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/cicd-tutorials/feedback","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cicd-tutorials%2Ffeedback","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cicd-tutorials%2Ffeedback/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cicd-tutorials%2Ffeedback/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cicd-tutorials%2Ffeedback/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cicd-tutorials","download_url":"https://codeload.github.com/cicd-tutorials/feedback/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cicd-tutorials%2Ffeedback/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28606142,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-20T14:45:23.139Z","status":"ssl_error","status_checked_at":"2026-01-20T14:44:16.929Z","response_time":117,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["django","docker-compose","example","example-application","svelte"],"created_at":"2025-12-30T04:59:54.875Z","updated_at":"2026-04-09T03:31:33.640Z","avatar_url":"https://github.com/cicd-tutorials.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Feedback\n\nThis is an example application for:\n\n- Learning basics of how modern web applications are built with HTML, CSS, JavaScript, servers, and databases.\n- Deploying something a little more advanced than a _Hello world!_ page or unconfigured nginx server.\n- Deploying a minimal production like application.\n\n## Getting started\n\nTo run the application on your machine, you will need [Docker Compose](https://docs.docker.com/compose/). You can either install it manually or by using Docker Desktop (see also [Installing Docker](https://cicd-tutorials.net/#installing-docker)).\n\nTo start the application, run `docker compose up -d`. This will build the container images and start the containers in the background. The application should be soon available in [http://localhost:8080](http://localhost:8080). The API container will create two demo forms, which are available in [/thumbs](http://localhost:8080/thumbs) and [/weather](http://localhost:8080/weather) paths.\n\n```sh\ndocker compose up -d\n```\n\nIf you want to inspect the admin panel, print out the content of `/var/feedback/initial_admin_password` in the `api` container with `docker compose exec` for the initial admin account password. Admin panel is available in [http://localhost:8080/admin](http://localhost:8080/admin) and the username for the admin account is `admin`.\n\n```sh\ndocker compose exec api cat /var/feedback/initial_admin_password\n```\n\nFor a more production like deployment, there is an OpenTofu/Terraform configuration example in [iac/tf](./iac/tf/) directory.\n\n\u003c!-- TODO: configuration --\u003e\n\n## Three-tier architecture\n\nThe application is built using a three-tier architecture. I.e., the application consists of presentation tier, application tier and a data tier.\n\n```mermaid\nflowchart LR\n  subgraph d [Data tier]\n    db[\"Database\u003cbr\u003e_(Postgres)_\"]\n  end\n  subgraph a [\"Application tier\u003cbr\u003e_(Gunicorn server)_\"]\n    a_whitespace:::hidden\n    admin[Admin interface]\n    API[\"API\u003cbr\u003e_(Django application)_\"]\n    static_build[Static files]\n  end\n  subgraph p [\"Presentation tier\u003cbr\u003e_(Nginx server)_\"]\n    p_whitespace:::hidden\n    root[\"/ _(Svelte application)_\"]\n    api_proxy[\"/api\"]\n    admin_proxy[\"/admin\"]\n    static[\"/static\"]\n  end\n\n  Browser--Static web page--\u003eroot\n  Browser--Dynamic web page--\u003eadmin_proxy\n  Browser-.-\u003eapi_proxy\n  Browser-.-\u003estatic\n\n  admin_proxy--proxy_pass--\u003eadmin\n  api_proxy--proxy_pass--\u003eAPI\n  static -.docker build.- static_build\n\n  admin--\u003edb\n  API--\u003edb\n\n  classDef hidden display: none;\n```\n\n\u003c!-- TODO: paragraph or two on how these are executed with docker compose --\u003e\n\n### Presentation tier\n\n\u003c!-- TODO: Intro to presentation tier --\u003e\n\nThe end-user facing user interface (UI) of the application is implemented as Svelte application in [front-end](./front-end) directory. [Svelte](https://svelte.dev/) is a JavaScript user-interface (UI) framework used to build the HTML, JavaScript, and CSS files that the browser renders and runs on the users machine.\n\nThe HTML, JavaScript, and CSS files that implement the user UI are delivered to the end-users browser by an static file server. This application uses [nginx](https://nginx.org/) which, in addition to hosting the static files, acts as a reverse proxy towards the application layer: when the user interacts with the application, the browser contacts the presentation tier which then proxies the requests to application tier. \u003c!-- TODO: add note on preventing direct access to the application server --\u003e\n\nThe presentation tier of an web application can be implemented either as a static or a dynamic web page. This application provides example for both of these: the end-user facing application is a static web page and the admin panel is a dynamic web page.\n\n#### Static web pages\n\nThe word static in static web page means that the files served to the browser are always the same regardless of the user or status of the server. This does not mean that the web page could not be interactive. Many static web pages include JavaScript logic that can change the content of the page from within the web browser by utilizing, for example, calls to a API of a web application.\n\nTo summarize, the initial page load will be the same for all users, but the page might use client side logic and API calls to add interaction and to personalize the content visible to the user.\n\n\u003c!-- TODO: paragraph about the Svelte app and e.g. client side routing --\u003e\n\n#### Dynamic web pages\n\nDynamic web pages, on the other hand, are created dynamically by the web server varying on, for example, the user making requesting the page or the status of the server. Thus, the content returned to the browser when loading the page will already be personalized on the server side.\n\n\u003c!-- TODO: paragraph about the Django admin panel --\u003e\n\nDynamic web pages often also utilize static content, such as stylesheets and images, to make the page load more efficient. The static content is usually served to the end-user by a static file server instead of the application server. In this application, the static filed required by the admin panel are copied to the nginx server during the Docker build process and served to the end-user from under `/static` path of the server.\n\n### Application tier\n\n\u003c!-- TODO: Intro to application tier --\u003e\n\nThe application tier of the application is implemented with Django in [back-end](./back-end) directory. [Django](https://www.djangoproject.com/) is a Python web framework that could be used to implement both presentation and the application tiers as well as managing the data tier. In this project it implements the application programming interface (API), handles interaction with the database, and provides an administrator panel for managing the data stored in the database.\n\nThe Django application is exposed using a [Gunicorn](https://gunicorn.org/) server that handles incoming connections using multiple worker threads. I.e., Gunicorn implements a production ready webserver where as the Django application is responsible for implementing the application logic behind the server. Gunicorn server communicates with the Django application using WSGI, a standardized interface for connecting web servers to applications.\n\n\u003c!-- TODO: more detailed description of the DB connection --\u003e\n\n### Data tier\n\n\u003c!-- TODO: Intro to data tier --\u003e\n\nThe data tier of the application is provided by Postgres SQL database. The application interacts with the database using Object-Relational Mapping (ORM) of the Django web framework.\n\nThe database is only exposed to the private network that connects the application containers to each other. This prevents external access to the database.\n\n\u003c!-- TODO: move relevant parts from here to sections above and/or to README.md files in back-end and front-end directories.\n\nThis section describes step-by-step how this application was created. To be able to follow these step-by-step instructions, you will need:\n\n- Web browser, e.g. Firefox or Chrome\n- Recent version of `python` and `pip` installed\n- Recent version of `docker` and `docker-compose` installed\n\n### 1. HTML page\n\nWe will start creating the application from the HTML (Hypertext Markup Language) file that defines the elements in our front-end. Create a directory called `front-end` and file called `index.html` in that directory, for example, by using `mkdir` and `touch` commands.\n\n```sh\nmkdir front-end\ntouch front-end/index.html\n```\n\nThen, add following content to the `front-end/index.html` file:\n\n```html\n\u003chtml lang=\"en\"\u003e\n  \u003chead\u003e\n    \u003ctitle\u003eFeedback?\u003c/title\u003e\n    \u003cmeta charset=\"UTF-8\" /\u003e\n  \u003c/head\u003e\n  \u003cbody\u003e\n    \u003cmain\u003e\n      \u003ch1\u003eHow was your experience with us?\u003c/h1\u003e\n      \u003cdiv\u003e\n        \u003cbutton type=\"button\"\u003e👍\u003c/button\u003e\n        \u003cbutton type=\"button\"\u003e👎\u003c/button\u003e\n      \u003c/div\u003e\n    \u003c/main\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nYou can open this file with your web browser. It will be rendered with default styling and the buttons will not do anything yet, though.\n\n### 2. CSS styles\n\nNext, we will add styling to our front-end with CSS (Cascading Style Sheets). Create `styles.css` file in the front-end directory.\n\n```sh\ntouch front-end/styles.css\n```\n\nThen, add following content to the `front-end/styles.css` file:\n\n```css\n/* Import the font we will be using */\n@import url(\"https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@200;300;400\u0026display=swap\");\n\n/* Define CSS variables */\n:root {\n  --black: rgb(34, 34, 38);\n  --white: rgb(221, 221, 221);\n  --white-25: rgba(221, 221, 221, 0.25);\n}\n\nbody {\n  /* Remove default margins added by the browser */\n  margin: 0 0;\n\n  /* Set background color, and global styles */\n  background: var(--black);\n  color: var(--white);\n  font-family: \"Source Sans Pro\", sans-serif;\n\n  /* Use flexbox to allow main element to grow according to screen size */\n  display: flex;\n  flex-direction: column;\n  min-height: 100vh;\n}\n\nmain {\n  /* Take all available vertical space */\n  flex-grow: 1;\n\n  /* Center elements horizontally and vertically */\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n\n  /* Add margins on smaller screens */\n  max-width: 95%;\n  margin: 1rem auto;\n}\n\n/* Center text in the header */\nheader {\n  text-align: center;\n}\n\n/* Override default h1 styling */\nh1 {\n  font-size: 1.5em;\n  font-weight: 300;\n  margin: 1em 0;\n}\n\np {\n  /* Use black text on white background and override default p styling */\n  background: var(--white);\n  color: var(--black);\n  font-size: 3rem;\n  font-weight: normal;\n  line-height: 1.25em;\n  margin: 3rem 0;\n  padding: 0 0.25em;\n}\n\nbutton {\n  /* Override default button styles */\n  background: transparent;\n  border: none;\n\n  /* Add custom styles */\n  border-radius: 50%;\n  cursor: pointer;\n  font-size: 4rem;\n\n  /* Configure spacing */\n  margin: 1rem;\n  padding: 2rem;\n\n  /* Add transition animations */\n  transition: all 250ms ease-in-out;\n}\n\n/* Add semi transparent background and rotate emoji slightly when mouse hovers on the elements or user navigates to the element with their keyboard */\nbutton:hover,\nbutton:focus-visible {\n  background: var(--white-25);\n  outline: none;\n  transform: rotate(15deg);\n}\n```\n\nIf you now reload `index.html` in your browser, the appearance of the page will not change. This is because the created stylesheet is not referenced in the HTML. To tell the browser to load the stylesheet, add `\u003clink\u003e` element to the `index.html` file according to the diff output below.\n\n```diff\ndiff --git a/front-end/index.html b/front-end/index.html\nindex bb511f9..a5a9409 100644\n--- a/front-end/index.html\n+++ b/front-end/index.html\n@@ -1,6 +1,7 @@\n \u003chtml lang=\"en\"\u003e\n   \u003chead\u003e\n     \u003ctitle\u003eFeedback\u003c/title\u003e\n+    \u003clink rel=\"stylesheet\" href=\"./styles.css\" /\u003e\n   \u003c/head\u003e\n   \u003cbody\u003e\n     \u003cheader\u003e\n```\n\nTry now to reload the page. The page should look different now.\n\n### 3. Server\n\nNext we will create a server to handle the incoming feedback.\n\nCreate a new `back-end` directory. Then, in the just created directory, create requirements.txt and server.py files.\n\n```sh\nmkdir back-end\ntouch back-end/requirements.txt\ntouch back-end/server.py\n```\n\nThe `requirements.txt` file defines libraries we will need in order to be able to run the server. Add following content to that file:\n\n```txt\ngunicorn\nflask\n```\n\nThe `server.py` file implements our initial server. Add following content to that file:\n\n```py\nfrom flask import Flask, request\n\napp = Flask(__name__)\n\ndata = dict(positive=0, negative=0)\n\n\ndef get_feedback():\n    return data\n\n\ndef post_feedback(input):\n    if input[\"type\"] == \"positive\":\n        data['positive'] += 1\n    if input[\"type\"] == \"negative\":\n        data['negative'] += 1\n\n\n@app.route(\"/feedback\", methods=['GET', 'POST'])\ndef feedback():\n    if request.method == \"POST\":\n        post_feedback(request.json)\n        return '', 204\n    return get_feedback(), 200\n```\n\nNote that data is now stored to a local varible in the `server` module. Thus data is resetted on every restart.\n\nTo run the server in development mode, first install dependencies with `pip3 install` and then use `flask run` command:\n\n```sh\npip3 install -r requirements.txt\nflask -A \"server:app\" run\n```\n\nWhile the server is running, you can test it with, for example, curl.\n\n```sh\n# Get current feedback overview\ncurl localhost:5000/feedback\n\n# Post new feedback\ncurl -X POST -d '{\"type\":\"positive\"}' -H \"Content-Type: application/json\" localhost:5000/feedback\n```\n\n### 4. In memory database\n\nNext, we will add database connection to our server. For that we will need two additional dependencies: `Flask-SQLAlchemy` and `SQLAlchemy`. Add these to the `back-end/requirements.py` according to the diff output below.\n\n```diff\ndiff --git a/back-end/requirements.txt b/back-end/requirements.txt\nindex cef5a16..9933987 100644\n--- a/back-end/requirements.txt\n+++ b/back-end/requirements.txt\n@@ -1,2 +1,4 @@\n Flask\n+Flask-SQLAlchemy\u003e=3.0.0\n gunicorn\n+SQLAlchemy\u003e=2.0.0b1\n```\n\nInstall the dependencies new dependencies with `pip3 install` command.\n\n```sh\npip3 install -r requirements.txt\n```\n\nWe will then configure our server to use SQLite in memory database. Modify `back-end/requirements.py` file according to the diff below.\n\n```diff\ndiff --git a/back-end/server.py b/back-end/server.py\nindex cbb4b3b..47b2df3 100644\n--- a/back-end/server.py\n+++ b/back-end/server.py\n@@ -1,24 +1,86 @@\n-from flask import Flask, request\n+from dataclasses import dataclass\n+from datetime import datetime\n+from os import getenv\n+from time import sleep\n+from uuid import uuid4\n+\n+from flask import Flask, jsonify, request\n+from flask_sqlalchemy import SQLAlchemy\n+from sqlalchemy import func\n\n app = Flask(__name__)\n+app.config['SQLALCHEMY_DATABASE_URI'] = getenv('DB_URL', 'sqlite:///:memory:')\n+app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False\n+db = SQLAlchemy(app)\n+\n+\n+@dataclass\n+class FeedbackItem(db.Model):\n+    id: str\n+    type: str\n+    timestamp: datetime\n+\n+    id = db.Column(db.String(36), primary_key=True)\n+    type = db.Column(db.String(8))\n+    timestamp = db.Column(db.DateTime())\n\n-data = dict(positive=0, negative=0)\n+    @property\n+    def json(self):\n+        return dict(\n+            id=self.id,\n+            type=self.type,\n+            timestamp=f'{self.timestamp.isoformat()}Z')\n+\n+\n+# Create table if it does not exist\n+for _ in range(5):\n+    try:\n+        with app.app_context():\n+            db.create_all()\n+    except BaseException:\n+        sleep(2)\n\n\n def get_feedback():\n-    return data\n+    rows = db.session.execute(db.select(FeedbackItem)).all()\n+    return jsonify([row.FeedbackItem.json for row in rows])\n\n\n def post_feedback(input):\n-    if input[\"type\"] == \"positive\":\n-        data['positive'] += 1\n-    if input[\"type\"] == \"negative\":\n-        data['negative'] += 1\n+    id_ = str(uuid4())\n+\n+    db.session.add(FeedbackItem(\n+        id=id_,\n+        type=input[\"type\"],\n+        timestamp=datetime.utcnow()\n+    ))\n+    db.session.commit()\n+\n+    return dict(id=id_)\n+\n+\n+def get_feedback_summary():\n+    rows = db.session.execute(\n+        db.select(\n+            func.count(\n+                FeedbackItem.id),\n+            FeedbackItem.type).group_by(\n+            FeedbackItem.type)).all()\n+\n+    data = dict(positive=0, negative=0)\n+    for count, type_ in rows:\n+        data[type_] = count\n+\n+    return jsonify(data)\n\n\n @app.route(\"/feedback\", methods=['GET', 'POST'])\n def feedback():\n     if request.method == \"POST\":\n-        post_feedback(request.json)\n-        return '', 204\n+        return post_feedback(request.json), 200\n     return get_feedback(), 200\n+\n+\n+@app.route(\"/feedback/summary\", methods=['GET'])\n+def feedback_summary():\n+    return get_feedback_summary(), 200\n```\n\nYou can use same curl commands as in the [previous step](#3-server) to test the server.\n\nNote that the `GET /feedback` output is now different. This end-point now lists all feedback items with ids and timestamps. For the summary, we introduced a new end-point `GET /feedback/summary`.\n\n```sh\n# Get all feedback items\ncurl localhost:5000/feedback\n\n# Get current feedback overview\ncurl localhost:5000/feedback/summary\n```\n\n### 5. Containerized development setup\n\nNext, we will create container images for our front-end and back-end components, add database running in container, and run these three containers with `docker-compose`.\n\nTo do this, we will need to create Dockerfiles for our own containers and docker-compose configuration to define how to run these containers.\n\n```sh\ntouch front-end/Dockerfile\ntouch back-end/Dockerfile\ntouch docker-compose.yml\n```\n\nFirst, we will we add the following content to the Dockerfile in front-end directory.\n\n```Dockerfile\nFROM nginx:alpine\n\nCOPY index.html styles.css /usr/share/nginx/html/\n```\n\nSecond, we will add the following content to the Dockerfile in back-end directory.\n\n```Dockerfile\nFROM python:3.11-slim\n\nWORKDIR /app\nCOPY requirements.txt /app/\nRUN pip install -r requirements.txt \u0026\u0026 pip install psycopg2-binary\n\nCOPY server.py /app/\nENTRYPOINT [\"gunicorn\", \"server:app\"]\nCMD [\"-w\", \"4\", \"-b\", \"0.0.0.0:8000\"]\n```\n\nFinally, we will add the following content to the docker-compose.yaml file.\n\n```yaml\nversion: \"3.4\"\nservices:\n  api:\n    environment:\n      DB_URL: postgresql://user:pass@db:5432/feedback\n    build: ./back-end/\n    command: -w 4 -b 0.0.0.0:8000\n    ports:\n      - 5000:8000\n  ui:\n    build: ./front-end/\n    ports:\n      - 9080:80\n  db:\n    image: postgres:14\n    environment:\n      POSTGRES_USER: user\n      POSTGRES_PASSWORD: pass\n      POSTGRES_DB: feedback\n```\n\nTo run this docker-compose configuration, use `docker-compose up` command.\n\n```sh\ndocker-compose up\n```\n\nYou can use same `curl` commands as in steps [4](#4-in-memory-database) and [5](#5-containerized-development-setup) to test the server and database connection. Try to also terminate the containers (E.g., with `CTRL-C` or `docker-compose down` command) and launch them again. The data should now persist after restarting the server.\n\n### 6. Connecting front-end to the back-end\n\nFinally, we will configure or front-end to send feedback to server when buttons are clicked and show feedback summary after that.\n\nFor the browser to communicate with another server than to one serving the static content, we will need to configure our server to include [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) headers into its responses. To do this, we will install and use `flask-cors`.\n\nEdit `back-end/requirements.txt` according to diff below.\n\n```diff\ndiff --git a/back-end/requirements.txt b/back-end/requirements.txt\nindex 9933987..d872cfc 100644\n--- a/back-end/requirements.txt\n+++ b/back-end/requirements.txt\n@@ -1,4 +1,5 @@\n Flask\n+flask-cors\n Flask-SQLAlchemy\u003e=3.0.0\n gunicorn\n SQLAlchemy\u003e=2.0.0b1\n```\n\nIf you are running the server without containers, remember to run `pip3 install` again.\n\n```sh\npip3 install -r requirements.txt\n```\n\nEdit `back-end/server.py` according to diff below.\n\n```diff\ndiff --git a/back-end/server.py b/back-end/server.py\nindex 47b2df3..1124e94 100644\n--- a/back-end/server.py\n+++ b/back-end/server.py\n@@ -5,10 +5,12 @@ from time import sleep\n from uuid import uuid4\n\n from flask import Flask, jsonify, request\n+from flask_cors import CORS\n from flask_sqlalchemy import SQLAlchemy\n from sqlalchemy import func\n\n app = Flask(__name__)\n+CORS(app)\n app.config['SQLALCHEMY_DATABASE_URI'] = getenv('DB_URL', 'sqlite:///:memory:')\n app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False\n db = SQLAlchemy(app)\n```\n\nOn the front-end side we will need to new files, `script.js` and `config.js`. `script.js` includes function we will execute when user clicks one of the buttons available on the page. `config.js` can be used to define server URL when running this application in production.\n\nCreate these files with `touch`.\n\n```sh\ntouch front-end/script.js\ntouch front-end/config.js\n```\n\nAdd following content to `script.js`.\n\n```js\n\"use strict\";\n\nfunction baseUrl() {\n  try {\n    return serverUrl;\n  } catch (_) {\n    return \"http://localhost:5000\";\n  }\n}\n\nasync function sendFeedback(type) {\n  // Post feedback\n  // We will ignore possible fetch errors and non-ok HTTP status codes here and later\n  await fetch(`${baseUrl()}/feedback`, {\n    method: \"POST\",\n    mode: \"cors\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ type }),\n  });\n\n  // Get feedback summary\n  const res = await fetch(`${baseUrl()}/feedback/summary`, {\n    mode: \"cors\",\n  });\n\n  if (!res.ok) {\n    return;\n  }\n\n  // Hide buttons and display results summary\n  document.getElementById(\"buttons-container\").classList.add(\"hidden\");\n  document.getElementById(\"results-container\").classList.remove(\"hidden\");\n\n  // Set bar chart bar width and count\n  const results = await res.json();\n  const total = results.positive + results.negative;\n  [\"positive\", \"negative\"].forEach((type) =\u003e {\n    const value = results[type];\n    const barEl = document.getElementById(`results-bar-${type}`);\n    const countEl = document.getElementById(`results-count-${type}`);\n\n    barEl.style = `width: ${(value / total) * 100}%`;\n    countEl.textContent = value;\n  });\n}\n```\n\nAdd following content to `config.js`.\n\n```js\n// To define the server URL, set serverUrl variable here, e.g.:\n// const serverUrl = \"https://example.com\";\n```\n\nWe will also need to load these files and add markup for displaying the feedback summary to our HTML content. Edit `front-end/index.html` according to the diff below.\n\n```diff\ndiff --git a/front-end/index.html b/front-end/index.html\nindex 72b1100..df14048 100644\n--- a/front-end/index.html\n+++ b/front-end/index.html\n@@ -3,16 +3,30 @@\n     \u003ctitle\u003eFeedback\u003c/title\u003e\n     \u003cmeta charset=\"UTF-8\" /\u003e\n     \u003clink rel=\"stylesheet\" href=\"./styles.css\" /\u003e\n+    \u003cscript src=\"./config.js\"\u003e\u003c/script\u003e\n   \u003c/head\u003e\n   \u003cbody\u003e\n+    \u003cscript src=\"./script.js\"\u003e\u003c/script\u003e\n     \u003cheader\u003e\n       \u003ch1\u003eFeedback\u003c/h1\u003e\n     \u003c/header\u003e\n     \u003cmain\u003e\n       \u003cp\u003eHow are you feeling?\u003c/p\u003e\n-      \u003cdiv class=\"buttons\"\u003e\n-        \u003cbutton type=\"button\"\u003e👍\u003c/button\u003e\n-        \u003cbutton type=\"button\"\u003e👎\u003c/button\u003e\n+      \u003cdiv id=\"buttons-container\" class=\"buttons\"\u003e\n+        \u003cbutton onclick=\"sendFeedback('positive')\" type=\"button\"\u003e👍\u003c/button\u003e\n+        \u003cbutton onclick=\"sendFeedback('negative')\" type=\"button\"\u003e👎\u003c/button\u003e\n+      \u003c/div\u003e\n+      \u003cdiv id=\"results-container\" class=\"results hidden\"\u003e\n+        \u003cdiv class=\"results-row\"\u003e\n+          \u003cspan\u003e👍\u003c/span\u003e\n+          \u003cdiv id=\"results-bar-positive\" class=\"results-bar\"\u003e\u003c/div\u003e\n+          \u003cspan id=\"results-count-positive\"\u003e0\u003c/span\u003e\n+        \u003c/div\u003e\n+        \u003cdiv class=\"results-row\"\u003e\n+          \u003cspan\u003e👎\u003c/span\u003e\n+          \u003cdiv id=\"results-bar-negative\" class=\"results-bar\"\u003e\u003c/div\u003e\n+          \u003cspan id=\"results-count-negative\"\u003e0\u003c/span\u003e\n+        \u003c/div\u003e\n       \u003c/div\u003e\n     \u003c/main\u003e\n   \u003c/body\u003e\n```\n\nTo also include these two new files in our container image, edit `front-end/Dockerfile` according to the diff below.\n\n```diff\ndiff --git a/front-end/Dockerfile b/front-end/Dockerfile\nindex 0bb4206..3588daf 100644\n--- a/front-end/Dockerfile\n+++ b/front-end/Dockerfile\n@@ -1,3 +1,3 @@\n FROM nginx:alpine\n\n-COPY index.html styles.css /usr/share/nginx/html/\n+COPY index.html styles.css script.js config.js /usr/share/nginx/html/\n```\n\nWe will also need to define styles for these new elements. Edit `front-end/styles.css` according to the diff below.\n\n```diff\ndiff --git a/front-end/styles.css b/front-end/styles.css\nindex 9e966fc..8d1b0f9 100644\n--- a/front-end/styles.css\n+++ b/front-end/styles.css\n@@ -86,3 +86,25 @@ button:focus-visible {\n   outline: none;\n   transform: rotate(15deg);\n }\n+\n+.results {\n+  font-size: 2em;\n+  width: 100%;\n+}\n+\n+.results-row {\n+  display: flex;\n+  margin: 2rem 0;\n+}\n+\n+.results-bar {\n+  background: var(--white);\n+  margin: 0 1rem;\n+  padding: 0.25rem;\n+  transition: width 250ms ease-in-out;\n+  width: 0;\n+}\n+\n+.hidden {\n+  display: none;\n+}\n```\n\nAfter creating and editing the files, we will need to build the container and restart them to see the effects. Shutdown the local development setup with `CTRL-C` or by running `docker-compose down`. Then, run `docker-compose build` and `docker-compose up`.\n\n```sh\ndocker-compose build\ndocker-compose up\n```\n\nOpen then `http://localhost:8081` with your browser. You should see the feedback page, be able to post feedback by clicking the buttons, and see the feedback summary after clicking either one of the buttons.\n--\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcicd-tutorials%2Ffeedback","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcicd-tutorials%2Ffeedback","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcicd-tutorials%2Ffeedback/lists"}