Merge pull request #30 from microsoft/add_todo_app
Initial commit adding the TODO sample
|
@ -0,0 +1,113 @@
|
|||
# TODO Application Sample
|
||||
|
||||
This sample illustrates how Bridge to Kubernetes can be used to develop a microservice version of the celebrated TODO application on any Kubernetes cluster. This sample has been adapted from code provided by [TodoMVC](http://todomvc.com).
|
||||
|
||||
Most sample TODO applications are composed of a frontend and some kind of backend persistent storage. This extended sample adds a statistics component and breaks the application into a number of microservices, specifically:
|
||||
|
||||
- The frontend uses a Mongo database to persist TODO items;
|
||||
- The frontend writes add, complete and delete events to a RabbitMQ queue;
|
||||
- A statistics worker receives events from the RabbitMQ queue and updates a Redis cache;
|
||||
- A statistics API exposes the cached statistics for the frontend to show.
|
||||
|
||||
In all, this extended TODO application is composed of six inter-related components.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubectl CLI (connected to a cluster)
|
||||
- [Bridge to Kubernetes VS Code extension](https://aka.ms/bridge-to-k8s-vsc-extension)
|
||||
|
||||
## Deploy the application
|
||||
|
||||
In this example, we will use a local cluster, MiniKube.
|
||||
|
||||
First, create a namespace for the sample.
|
||||
|
||||
```
|
||||
kubectl create namespace todo-app
|
||||
```
|
||||
|
||||
Then, apply the deployment manifest:
|
||||
|
||||
```
|
||||
kubectl apply -n todo-app -f deployment.yaml
|
||||
```
|
||||
|
||||
This is a simple deployment that exposes the frontend using a service of type `LoadBalancer`. Wait for all the pods to be running and for the external IP of the `frontend` service to become available.
|
||||
|
||||
If you are testing with MiniKube, you will need to use `minikube tunnel` to resolve an external IP.
|
||||
|
||||
```
|
||||
kubectl get services -n todo-app
|
||||
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
frontend LoadBalancer 10.0.49.177 127.0.0.1 80:30145/TCP 18h
|
||||
```
|
||||
|
||||
Browse to the application using the external IP and give it a spin. As you add, complete and delete todos, notice that the stats page updates with the expected metrics
|
||||
|
||||
## Debug the stats-api service
|
||||
|
||||
We will now use the Bridge to Kubernetes extension to demonstrate how traffic from the Kubernetes cluster can be redirected to a locally running version of the stats-api.
|
||||
|
||||
```
|
||||
cd stats-api/
|
||||
```
|
||||
|
||||
Open the source code for the stats-api in VS Code.
|
||||
|
||||
```
|
||||
code .
|
||||
```
|
||||
|
||||
Place a breakpoint on line 17 of server.js.
|
||||
|
||||
Open the `Command Pallette (Ctrl + SHIFT + P) or (CMD + SHIFT + P)` and type Bridge to Kubernetes. Select the `"Bridge to Kubernetes: Configure"` option.
|
||||
|
||||
![](images/bridge_configure.png)
|
||||
|
||||
You are prompted to configure the service you want to replace, the port to forward from your development computer, and the launch task to use.
|
||||
|
||||
Choose the `stats-api` service.
|
||||
![](images/select_service.png)
|
||||
|
||||
After you select your service, you are prompted to enter the TCP port for your local application. For this example, enter `3001`.
|
||||
![](images/enter_port.png)
|
||||
|
||||
Choose `Run Script: dev` as the launch task.
|
||||
![](images/launch_task.png)
|
||||
|
||||
You have the option of running isolated or not isolated. If you run isolated, only your requests are routed to your local process; other developers can use the cluster without being affected. If you don't run isolated, all traffic is redirected to your local process. For more information on this option, see [Using routing capabilities for developing in isolation](https://docs.microsoft.com/en-us/visualstudio/containers/overview-bridge-to-kubernetes?view=vs-2019#using-routing-capabilities-for-developing-in-isolation). For this example, we will proceed with non-isolated.
|
||||
![](images/isolation.png)
|
||||
|
||||
> Note: You will be prompted to allow the EndpointManager to run elevated and modify your hosts file.
|
||||
|
||||
The Bridge to Kubernetes debugging profile has been successfully configured.
|
||||
|
||||
Select the Debug icon on the left and select `Run Script: dev with Bridge to Kubernetes`. Click the start button next to `Run Script: dev with Kubernetes`.
|
||||
|
||||
![](images/debug_profile.png)
|
||||
|
||||
Your development computer is connected when the VS Code status bar turns orange and the Kubernetes extension shows you are connected. Once your development computer is connected, traffic starts redirecting to your development computer for the stats-api you are replacing.
|
||||
|
||||
![](images/debugging.png)
|
||||
|
||||
|
||||
Navigate to the frontend entry point of your todo-app. For minikube, we'll be using `127.0.0.1`.
|
||||
|
||||
Make a request to the stats-api by clicking on the `stats` link.
|
||||
|
||||
![](images/stats.png)
|
||||
|
||||
Notice the traffic that initally started in your cluster was redirected to your locally running version (outside of the cluster) where the breakpoint was triggered.
|
||||
|
||||
Press play and let the request contine complete transparently.
|
||||
|
||||
This is just one example on how to use Bridge to Kubernetes on non-AKS clusters. Try it on your own project next!
|
||||
|
||||
## Clean up
|
||||
|
||||
To clean up the assets produced by this sample, simply run:
|
||||
|
||||
```
|
||||
kubectl delete namespace todo-app
|
||||
```
|
|
@ -0,0 +1,176 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: stats-cache
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: stats-cache
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: stats-cache
|
||||
spec:
|
||||
containers:
|
||||
- name: cache
|
||||
image: redis:5-alpine
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: stats-cache
|
||||
spec:
|
||||
selector:
|
||||
name: stats-cache
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: stats-queue
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: stats-queue
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: stats-queue
|
||||
spec:
|
||||
containers:
|
||||
- name: queue
|
||||
image: rabbitmq:3-alpine
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: stats-queue
|
||||
spec:
|
||||
selector:
|
||||
name: stats-queue
|
||||
ports:
|
||||
- port: 5672
|
||||
targetPort: 5672
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: stats-worker
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: stats-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: stats-worker
|
||||
spec:
|
||||
containers:
|
||||
- name: stats-worker
|
||||
image: azdspublic/todo-app-stats-worker
|
||||
env:
|
||||
- name: STATS_QUEUE_URI
|
||||
value: amqp://stats-queue
|
||||
- name: REDIS_HOST
|
||||
value: stats-cache
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: stats-api
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: stats-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: stats-api
|
||||
spec:
|
||||
containers:
|
||||
- name: stats-api
|
||||
image: azdspublic/todo-app-stats-api
|
||||
env:
|
||||
- name: REDIS_HOST
|
||||
value: stats-cache
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: stats-api
|
||||
spec:
|
||||
selector:
|
||||
name: stats-api
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: todos-db
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: todos-db
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: todos-db
|
||||
spec:
|
||||
containers:
|
||||
- name: todos-db
|
||||
image: mongo:4
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: todos-db
|
||||
spec:
|
||||
selector:
|
||||
name: todos-db
|
||||
ports:
|
||||
- port: 27017
|
||||
targetPort: 27017
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: azdspublic/todo-app-frontend
|
||||
env:
|
||||
- name: MONGO_CONNECTION_STRING
|
||||
value: mongodb://todos-db
|
||||
- name: STATS_QUEUE_URI
|
||||
value: amqp://stats-queue
|
||||
- name: STATS_API_HOST
|
||||
value: stats-api
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
name: frontend
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
|
@ -0,0 +1,7 @@
|
|||
.dockerignore
|
||||
.gitignore
|
||||
.next
|
||||
.vscode
|
||||
Dockerfile
|
||||
node_modules
|
||||
README.md
|
|
@ -0,0 +1,2 @@
|
|||
.next
|
||||
node_modules
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:lts-alpine
|
||||
ENV PORT 80
|
||||
EXPOSE 80
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "start"]
|
|
@ -0,0 +1,206 @@
|
|||
import React, { Component } from 'react';
|
||||
import TodoItem from './TodoItem';
|
||||
import TodoFooter from './TodoFooter';
|
||||
|
||||
class TodoApp extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
todos: [],
|
||||
settings: {
|
||||
ENTER_KEY: 13
|
||||
},
|
||||
newTodo: ''
|
||||
}
|
||||
|
||||
this.handleNewTodoKeyDown = this.handleNewTodoKeyDown.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.toggleTodo = this.toggleTodo.bind(this);
|
||||
this.deleteTodo = this.deleteTodo.bind(this);
|
||||
}
|
||||
|
||||
async callApi(method, routeUrl, body) {
|
||||
const res = await fetch(routeUrl, {
|
||||
method: method,
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||
return await res.json();
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async getTodos() {
|
||||
const data = await this.callApi("GET", "/api/todos");
|
||||
if (data) {
|
||||
this.setState({ todos: data });
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.getTodos();
|
||||
}
|
||||
|
||||
async addTodo(newTodoTitle) {
|
||||
const newTodo = await this.callApi("POST", "/api/todos", { title: newTodoTitle, completed: false });
|
||||
this.setState({ todos: this.state.todos.concat(newTodo) });
|
||||
}
|
||||
|
||||
async deleteTodo(todoToDelete) {
|
||||
await this.callApi("DELETE", "/api/todos/" + todoToDelete._id);
|
||||
this.getTodos();
|
||||
};
|
||||
|
||||
async toggleTodo(todoToToggle) {
|
||||
const updatedTodo = {
|
||||
title: todoToToggle.title,
|
||||
completed: !todoToToggle.completed
|
||||
}
|
||||
await this.callApi("PUT", "/api/todos/" + todoToToggle._id, updatedTodo);
|
||||
this.getTodos();
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
this.setState({ newTodo: event.target.value });
|
||||
}
|
||||
|
||||
handleNewTodoKeyDown(event) {
|
||||
if (event.keyCode !== this.state.settings.ENTER_KEY) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
var val = this.state.newTodo.trim();
|
||||
|
||||
if (val) {
|
||||
this.addTodo(val);
|
||||
this.setState({ newTodo: '' });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
var todos = this.state.todos;
|
||||
var todoItems = todos.map((todo) => {
|
||||
return (
|
||||
<TodoItem
|
||||
key={todo._id}
|
||||
id={todo._id}
|
||||
todo={todo}
|
||||
onToggle={() => this.toggleTodo(todo)}
|
||||
onDestroy={() => this.deleteTodo(todo)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
var activeTodoCount = todos.reduce(function (accum, todo) {
|
||||
return todo.completed ? accum : accum + 1;
|
||||
}, 0);
|
||||
|
||||
var footer = (
|
||||
<TodoFooter todoCount={activeTodoCount} />
|
||||
);
|
||||
|
||||
var credits = (
|
||||
<div className="credits">
|
||||
Adapted from <strong><a href="http://todomvc.com/">TodoMVC</a></strong>
|
||||
<style jsx>{`
|
||||
.credits {
|
||||
margin-top: 100px;
|
||||
color: #bbb;
|
||||
font: inherit;
|
||||
font-style: inherit;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.credits a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="TodoApp">
|
||||
<h1 className="app-title">todos</h1>
|
||||
<div className="main">
|
||||
<div className="edit-section">
|
||||
<input className="new-todo"
|
||||
placeholder="What needs to be done?"
|
||||
value={this.state.newTodo}
|
||||
onKeyDown={this.handleNewTodoKeyDown}
|
||||
onChange={this.handleChange}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
<ul className="todo-list">
|
||||
{todoItems}
|
||||
</ul>
|
||||
{footer}
|
||||
</div>
|
||||
{credits}
|
||||
<style jsx>{`
|
||||
.TodoApp {
|
||||
text-align: center;
|
||||
}
|
||||
.todo-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
.main {
|
||||
background-color: white;
|
||||
margin: 50px 0 40px 0;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
|
||||
0 25px 50px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.app-title {
|
||||
width: 100%;
|
||||
font-size: 96px;
|
||||
font-weight: 100;
|
||||
text-align: center;
|
||||
color: rgba(175, 47, 47, 0.5);
|
||||
-webkit-text-rendering: optimizeLegibility;
|
||||
-moz-text-rendering: optimizeLegibility;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.new-todo, .edit {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 24px;
|
||||
background: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: 1.4em;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 6px;
|
||||
}
|
||||
.new-todo::placeholder{
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
}
|
||||
.edit-section {
|
||||
padding: 10px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TodoApp;
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default class TodoFooter extends React.Component {
|
||||
pluralize(count, word) {
|
||||
return count === 1 ? word : word + 's';
|
||||
}
|
||||
|
||||
render() {
|
||||
var activeTodoWord = this.pluralize(this.props.todoCount, 'item');
|
||||
return (
|
||||
<div className="filters">
|
||||
<div className="todo-count">{this.props.todoCount} {activeTodoWord} left</div>
|
||||
<div className="stats-link"><Link prefetch href="/stats"><a>stats</a></Link></div>
|
||||
<style jsx>{`
|
||||
.filters {
|
||||
color: #777;
|
||||
padding: 5px 15px 30px 15px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
font-size: 14px;
|
||||
}
|
||||
.todo-count {
|
||||
float: left;
|
||||
text-align: left;
|
||||
}
|
||||
.stats-link {
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class TodoItem extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<li className={classNames({ completed: this.props.todo.completed })}>
|
||||
<div className="view">
|
||||
<input
|
||||
className="toggle"
|
||||
type="checkbox"
|
||||
checked={this.props.todo.completed}
|
||||
onChange={this.props.onToggle}
|
||||
/>
|
||||
<label>{this.props.todo.title}</label>
|
||||
<button className="destroy" onClick={this.props.onDestroy} />
|
||||
</div>
|
||||
<style jsx>{`
|
||||
li {
|
||||
position: relative;
|
||||
font-size: 24px;
|
||||
border-bottom: 1px solid #ededed;
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.toggle {
|
||||
text-align: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto 0;
|
||||
border: none; /* Mobile Safari */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.toggle + label {
|
||||
background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center left;
|
||||
}
|
||||
.toggle:checked + label {
|
||||
background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
|
||||
}
|
||||
label {
|
||||
word-break: break-all;
|
||||
padding: 15px 15px 15px 60px;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
.completed label {
|
||||
color: #d9d9d9;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.destroy {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: auto 0;
|
||||
font-size: 30px;
|
||||
color: #cc9a9a;
|
||||
margin-bottom: 11px;
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
.destroy:hover {
|
||||
color: #af5b5e;
|
||||
}
|
||||
.destroy:after {
|
||||
content: '×';
|
||||
}
|
||||
li:hover .destroy {
|
||||
display: block;
|
||||
color: blue;
|
||||
}
|
||||
`}</style>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
const withCSS = require('@zeit/next-css')
|
||||
module.exports = withCSS()
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "todo-frontend",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zeit/next-css": "^1.0.1",
|
||||
"body-parser": "^1.19.0",
|
||||
"classnames": "^2.2.6",
|
||||
"express": "^4.17.1",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"mongodb": "^3.5.8",
|
||||
"next": "^9.4.4",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"request": "^2.88.2",
|
||||
"servicebus": "^2.3.3"
|
||||
},
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon",
|
||||
"build": "next build",
|
||||
"start": "node server.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import App, {Container} from 'next/app'
|
||||
import Head from 'next/head'
|
||||
import React from 'react'
|
||||
|
||||
export default class MyApp extends App {
|
||||
static async getInitialProps ({ Component, ctx }) {
|
||||
let pageProps = {};
|
||||
|
||||
if (Component.getInitialProps) {
|
||||
pageProps = await Component.getInitialProps(ctx);
|
||||
}
|
||||
|
||||
return { pageProps };
|
||||
}
|
||||
|
||||
render () {
|
||||
const { Component, pageProps } = this.props;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Head>
|
||||
<title>Todo App Sample</title>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// _document is only rendered on the server side and not on the client side
|
||||
// Event handlers like onClick can't be added to this file
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { ...initialProps };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossOrigin="anonymous"></link>
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossOrigin="anonymous"></link>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument;
|
|
@ -0,0 +1,15 @@
|
|||
import React, { Component } from 'react'
|
||||
import TodoApp from '../components/TodoApp'
|
||||
import '../public/static/index.css'
|
||||
|
||||
export default class Index extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TodoApp />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link'
|
||||
import fetch from 'isomorphic-fetch'
|
||||
import '../public/static/index.css'
|
||||
|
||||
export default class Stats extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
stats: {
|
||||
todosCreated: null,
|
||||
todosCompleted: null,
|
||||
todosDeleted: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async callStatsApi(method, routeUrl, body) {
|
||||
const url = routeUrl;
|
||||
const res = await fetch(url, {
|
||||
method: method,
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||
return await res.json();
|
||||
}
|
||||
else {
|
||||
return res.text();
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const data = await this.callStatsApi("GET", "/api/stats");
|
||||
if (data) {
|
||||
this.setState({ stats: data });
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.getStats();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="container-fluid app-title">
|
||||
<Link prefetch href="/">
|
||||
<h1 className="app-title"><i className="fas fa-arrow-left"></i> todo <span className="no-color">stats</span></h1>
|
||||
</Link>
|
||||
<div className="row">
|
||||
<div className="col">Created</div>
|
||||
<div className="col">Completed</div>
|
||||
<div className="col">Deleted</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col todo-metric">{this.state.stats.todosCreated}</div>
|
||||
<div className="col todo-metric">{this.state.stats.todosCompleted}</div>
|
||||
<div className="col todo-metric">{this.state.stats.todosDeleted}</div>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.container-fluid {
|
||||
text-align:center;
|
||||
max-width: 800px;
|
||||
}
|
||||
.app-title {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: 96px;
|
||||
font-weight: 100;
|
||||
text-align: center;
|
||||
color: rgba(175, 47, 47, 0.5);
|
||||
-webkit-text-rendering: optimizeLegibility;
|
||||
-moz-text-rendering: optimizeLegibility;
|
||||
text-rendering: optimizeLegibility;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
i {
|
||||
font-size: 70px;
|
||||
}
|
||||
.no-color {
|
||||
color: black;
|
||||
}
|
||||
.row {
|
||||
font-size: 30px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.todo-metric {
|
||||
color: black;
|
||||
font-size: 40px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
body {
|
||||
margin: 0 auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.4em;
|
||||
background: #f5f5f5;
|
||||
color: #4d4d4d;
|
||||
min-width: 230px;
|
||||
max-width: 550px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
const next = require('next');
|
||||
const express = require('express')
|
||||
const bodyParser = require("body-parser");
|
||||
const request = require('request');
|
||||
const mongodb = require("mongodb");
|
||||
const serviceBus = require('servicebus');
|
||||
const http = require('http');
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = express();
|
||||
server.use(bodyParser.json());
|
||||
|
||||
const mongo = mongodb.MongoClient.connect(process.env.MONGO_CONNECTION_STRING, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
});
|
||||
mongo.then(() => console.log("Connected to Mongo server"));
|
||||
mongo.catch(reason => {
|
||||
console.error(reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.get("/api/todos", function (req, res) {
|
||||
mongo.then(client => {
|
||||
const todos = client.db("todos").collection("todos");
|
||||
todos.find({}).toArray((err, docs) => {
|
||||
if (err) {
|
||||
res.status(500).send(err);
|
||||
} else {
|
||||
res.send(docs);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var bus = serviceBus.bus({ url: process.env.STATS_QUEUE_URI });
|
||||
bus.use(bus.logger());
|
||||
bus.on("error", err => {
|
||||
console.error(err.message)
|
||||
process.exit(1);
|
||||
})
|
||||
|
||||
function updateStats(updateEvent) {
|
||||
console.log("Pushing stats update: " + updateEvent);
|
||||
bus.send(updateEvent, { todo: updateEvent });
|
||||
}
|
||||
|
||||
server.post('/api/todos', function (req, res) {
|
||||
console.log("POST /api/todos");
|
||||
if (!req.body) {
|
||||
res.status(400).send("missing item");
|
||||
return;
|
||||
}
|
||||
mongo.then(client => {
|
||||
const todos = client.db("todos").collection("todos");
|
||||
todos.insertOne(req.body, (err, result) => {
|
||||
if (err) {
|
||||
res.status(500).send(err);
|
||||
} else {
|
||||
res.status(201).send(req.body);
|
||||
updateStats('todo.created');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.put("/api/todos/:id", function (req, res) {
|
||||
const id = req.params.id;
|
||||
console.log("PUT /api/todos/" + id);
|
||||
if (!mongodb.ObjectID.isValid(id)) {
|
||||
res.status(400).send("invalid id");
|
||||
return;
|
||||
}
|
||||
if (req.body && req.body._id) {
|
||||
res.status(500).send({ message: "Request body should not contain '_id' field." });
|
||||
return;
|
||||
}
|
||||
mongo.then(client => {
|
||||
const todos = client.db("todos").collection("todos");
|
||||
todos.updateOne({ _id: new mongodb.ObjectId(id) }, { $set: req.body }, (err, result) => {
|
||||
if (err) {
|
||||
res.status(500).send(err);
|
||||
} else if (result.matchedCount == 0) {
|
||||
res.sendStatus(404);
|
||||
} else {
|
||||
res.sendStatus(204);
|
||||
if (req.body.completed === true) {
|
||||
updateStats('todo.completed');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.delete("/api/todos/:id", function (req, res) {
|
||||
const id = req.params.id;
|
||||
console.log("DELETE /api/todos/" + id);
|
||||
if (!mongodb.ObjectID.isValid(id)) {
|
||||
res.status(400).send("invalid id");
|
||||
return;
|
||||
}
|
||||
mongo.then(client => {
|
||||
const todos = client.db("todos").collection("todos");
|
||||
todos.deleteOne({ _id: new mongodb.ObjectId(id) }, (err, result) => {
|
||||
if (err) {
|
||||
res.status(500).send(err);
|
||||
} else if (result.deletedCount == 0) {
|
||||
res.sendStatus(404);
|
||||
} else {
|
||||
res.sendStatus(204);
|
||||
updateStats('todo.deleted');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.get("/api/stats", function (req, res) {
|
||||
var options = {
|
||||
host: process.env.STATS_API_HOST,
|
||||
path: '/stats',
|
||||
method: 'GET'
|
||||
};
|
||||
const val = req.get('kubernetes-route-as');
|
||||
if (val) {
|
||||
console.log('Forwarding kubernetes-route-as header value - %s', val);
|
||||
options.headers = {
|
||||
'kubernetes-route-as': val
|
||||
}
|
||||
}
|
||||
var req = http.request(options, function(statResponse) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
var responseString = '';
|
||||
//another chunk of data has been received, so append it to `responseString`
|
||||
statResponse.on('data', function (chunk) {
|
||||
responseString += chunk;
|
||||
});
|
||||
statResponse.on('end', function () {
|
||||
res.send(responseString);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', function(e) {
|
||||
console.log('problem with request: ' + e.message);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
server.get('/', (req, res) => {
|
||||
console.log("Serving index");
|
||||
return app.render(req, res, '/index', {});
|
||||
});
|
||||
|
||||
server.get('*', (req, res) => {
|
||||
return handle(req, res);
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
server.listen(port, err => {
|
||||
if (err) throw err;
|
||||
console.log(`> Ready on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit(130 /* 128 + SIGINT */);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
bus.close();
|
||||
server.close(() => {
|
||||
process.exit(143 /* 128 + SIGTERM */);
|
||||
});
|
||||
});
|
||||
});
|
После Ширина: | Высота: | Размер: 19 KiB |
После Ширина: | Высота: | Размер: 80 KiB |
После Ширина: | Высота: | Размер: 361 KiB |
После Ширина: | Высота: | Размер: 24 KiB |
После Ширина: | Высота: | Размер: 39 KiB |
После Ширина: | Высота: | Размер: 21 KiB |
После Ширина: | Высота: | Размер: 24 KiB |
После Ширина: | Высота: | Размер: 35 KiB |
|
@ -0,0 +1,7 @@
|
|||
.dockerignore
|
||||
.gitignore
|
||||
.queues
|
||||
.vscode
|
||||
Dockerfile
|
||||
node_modules
|
||||
README.md
|
|
@ -0,0 +1,2 @@
|
|||
.queues
|
||||
node_modules
|
|
@ -0,0 +1,10 @@
|
|||
FROM node:lts-alpine
|
||||
ENV PORT 80
|
||||
EXPOSE 80
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "start"]
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "todo-stats-api",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.17.1",
|
||||
"redis": "^3.0.2"
|
||||
},
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon",
|
||||
"start": "node server.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
var express = require('express');
|
||||
var redis = require('redis');
|
||||
|
||||
var app = express();
|
||||
|
||||
var cache = redis.createClient({
|
||||
host: process.env.REDIS_HOST,
|
||||
port: process.env.REDIS_PORT,
|
||||
tls: process.env.REDIS_SSL == "true" ? {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: process.env.REDIS_PORT,
|
||||
} : undefined,
|
||||
password: process.env.REDIS_PASSWORD || undefined
|
||||
});
|
||||
|
||||
app.get('/stats', function (req, res) {
|
||||
console.log('request for stats received with kubernetes-route-as header: %s', req.get('kubernetes-route-as'));
|
||||
cache.get('todosCreated', function (err, created) {
|
||||
cache.get('todosCompleted', function (err, completed) {
|
||||
cache.get('todosDeleted', function (err, deleted) {
|
||||
res.send({
|
||||
todosCreated: created || 0,
|
||||
todosCompleted: completed || 0,
|
||||
todosDeleted: deleted || 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var port = process.env.PORT || 3001;
|
||||
var server = app.listen(port, function () {
|
||||
console.log('Listening on port ' + port);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit(130 /* 128 + SIGINT */);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
bus.close();
|
||||
cache.quit();
|
||||
server.close(() => {
|
||||
process.exit(143 /* 128 + SIGTERM */);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
.dockerignore
|
||||
.gitignore
|
||||
.queues
|
||||
.vscode
|
||||
Dockerfile
|
||||
node_modules
|
||||
README.md
|
|
@ -0,0 +1,2 @@
|
|||
.queues
|
||||
node_modules
|
|
@ -0,0 +1,8 @@
|
|||
FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "start"]
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "todo-stats-worker",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"redis": "^3.0.2",
|
||||
"servicebus": "^2.3.3"
|
||||
},
|
||||
"main": "worker.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon",
|
||||
"start": "node worker.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
var serviceBus = require('servicebus');
|
||||
var redis = require('redis');
|
||||
|
||||
var bus = serviceBus.bus({ url: process.env.STATS_QUEUE_URI });
|
||||
bus.use(bus.logger());
|
||||
bus.on("error", err => {
|
||||
console.error(err.message)
|
||||
process.exit(1);
|
||||
})
|
||||
|
||||
var cache = redis.createClient({
|
||||
host: process.env.REDIS_HOST,
|
||||
port: process.env.REDIS_PORT,
|
||||
});
|
||||
|
||||
function updateStat(stat) {
|
||||
console.log("Updating stat: " + stat);
|
||||
cache.incr(stat);
|
||||
}
|
||||
|
||||
bus.listen('todo.created', function (event) {
|
||||
updateStat('todosCreated');
|
||||
});
|
||||
|
||||
bus.listen('todo.completed', function (event) {
|
||||
updateStat('todosCompleted');
|
||||
});
|
||||
|
||||
bus.listen('todo.deleted', function (event) {
|
||||
updateStat('todosDeleted');
|
||||
});
|