feat(devops): add Docker scripts
This commit is contained in:
parent
da9ee48962
commit
9e150a4efe
19 changed files with 449 additions and 17 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Frontend build output
|
||||
frontend/.gitignore
|
||||
frontend/.svelte-kit
|
||||
frontend/build
|
||||
frontend/node_modules
|
||||
frontend/package
|
||||
frontend/vite.config.js.timestamp-*
|
||||
frontend/vite.config.ts.timestamp-*
|
||||
|
||||
# Backend cache files and virtualenv
|
||||
backend/.venv/
|
||||
*.pyc
|
||||
__pycache__/
|
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Local database
|
||||
/.postgres/
|
||||
|
||||
# Latex compiled files
|
||||
**/*.aux
|
||||
**/*.bbl
|
||||
**/*.bcf
|
||||
**/*.blg
|
||||
**/*.gz
|
||||
**/*.log
|
||||
**/*.out
|
||||
**/*.run.xml
|
||||
**/*.pdf
|
||||
|
||||
# Drawio backup and lock files
|
||||
**/*.drawio.bkp
|
||||
**/*.drawio.dtmp
|
14
Caddyfile
Normal file
14
Caddyfile
Normal file
|
@ -0,0 +1,14 @@
|
|||
:8000 {
|
||||
handle_path /api/* {
|
||||
reverse_proxy * todo-backend:3000
|
||||
}
|
||||
|
||||
handle * {
|
||||
reverse_proxy * todo-frontend:3000
|
||||
}
|
||||
|
||||
log {
|
||||
output stderr
|
||||
format console
|
||||
}
|
||||
}
|
25
development.backend.Dockerfile
Normal file
25
development.backend.Dockerfile
Normal file
|
@ -0,0 +1,25 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:alpine
|
||||
|
||||
# Create non-root user
|
||||
ARG CUSTOM_UID
|
||||
ARG CUSTOM_GID
|
||||
ENV CUSTOM_USERNAME=backend
|
||||
ENV CUSTOM_GROUPNAME=backend
|
||||
RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \
|
||||
adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \
|
||||
mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app
|
||||
ENV PATH "$PATH:/home/${CUSTOM_GROUPNAME}/.local/bin"
|
||||
|
||||
# Copy source files
|
||||
WORKDIR /app
|
||||
COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} backend/ /app/
|
||||
|
||||
# Install dependencies
|
||||
USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000}
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Run ASGI server
|
||||
EXPOSE 3000/tcp
|
||||
ENTRYPOINT ["uvicorn", "todo.main:app", "--root-path", "/api", "--host", "0.0.0.0", "--port", "3000", "--access-log", "--use-colors", "--log-level", "debug", "--reload"]
|
93
development.docker-compose.yml
Normal file
93
development.docker-compose.yml
Normal file
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
todo-webserver:
|
||||
container_name: todo-webserver
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: development.webserver.Dockerfile
|
||||
args:
|
||||
CUSTOM_UID: 1000
|
||||
CUSTOM_GID: 1000
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./Caddyfile:/app/Caddyfile
|
||||
todo-frontend:
|
||||
container_name: todo-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- todo-webserver
|
||||
build:
|
||||
context: .
|
||||
dockerfile: development.frontend.Dockerfile
|
||||
args:
|
||||
CUSTOM_UID: 1000
|
||||
CUSTOM_GID: 1000
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ./frontend/postcss.config.js:/app/postcss.config.js:ro
|
||||
- ./frontend/svelte.config.js:/app/svelte.config.js:ro
|
||||
- ./frontend/tailwind.config.js:/app/tailwind.config.js:ro
|
||||
- ./frontend/tsconfig.json:/app/tsconfig.json:ro
|
||||
- ./frontend/vite.config.ts:/app/vite.config.ts:ro
|
||||
- ./frontend/src:/app/src:ro
|
||||
- ./frontend/static:/app/static:ro
|
||||
todo-backend:
|
||||
container_name: todo-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- todo-webserver
|
||||
- todo-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./development.backend.Dockerfile
|
||||
args:
|
||||
CUSTOM_UID: 1000
|
||||
CUSTOM_GID: 1000
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ./backend/todo/:/app/todo:ro
|
||||
- ./backend/requirements.txt:/app/requirements.txt:ro
|
||||
environment:
|
||||
APP_NAME: "TodoApp"
|
||||
ADMIN_EMAIL: "admin@example.com"
|
||||
DEBUG_MODE: "true"
|
||||
POSTGRES_HOST: "todo-db"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "todo"
|
||||
POSTGRES_USER: "todo"
|
||||
POSTGRES_PASSWORD: "todo"
|
||||
todo-db:
|
||||
image: postgres:alpine
|
||||
container_name: todo-db
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- ./.postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: "todo"
|
||||
POSTGRES_USER: "todo"
|
||||
POSTGRES_PASSWORD: "todo"
|
||||
todo-pgweb:
|
||||
image: sosedoff/pgweb
|
||||
container_name: todo-pgweb
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- todo-db
|
||||
ports:
|
||||
- "8001:8081"
|
||||
environment:
|
||||
DATABASE_URL: "postgres://todo:todo@todo-db:5432/todo?sslmode=disable"
|
||||
|
||||
...
|
28
development.frontend.Dockerfile
Normal file
28
development.frontend.Dockerfile
Normal file
|
@ -0,0 +1,28 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
# Install npm and nodejs
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# Create non-root user
|
||||
ARG CUSTOM_UID
|
||||
ARG CUSTOM_GID
|
||||
ENV CUSTOM_USERNAME=frontend
|
||||
ENV CUSTOM_GROUPNAME=frontend
|
||||
RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \
|
||||
adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \
|
||||
mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app
|
||||
|
||||
# Copy source files
|
||||
COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} frontend/ /app/
|
||||
|
||||
# Install dependencies
|
||||
USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000}
|
||||
WORKDIR /app/
|
||||
RUN npm install
|
||||
|
||||
# Run vite dev server
|
||||
ENV NODE_ENV=development
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["npm", "run", "dev"]
|
24
development.webserver.Dockerfile
Normal file
24
development.webserver.Dockerfile
Normal file
|
@ -0,0 +1,24 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
# Install caddy
|
||||
RUN apk add --no-cache caddy
|
||||
|
||||
# Create non-root user
|
||||
ARG CUSTOM_UID
|
||||
ARG CUSTOM_GID
|
||||
ENV CUSTOM_USERNAME=webserver
|
||||
ENV CUSTOM_GROUPNAME=webserver
|
||||
RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \
|
||||
adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \
|
||||
mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app
|
||||
|
||||
# Copy caddy config
|
||||
WORKDIR /app
|
||||
COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} Caddyfile /app/
|
||||
|
||||
# Run Caddy in development mode
|
||||
USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000}
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["caddy", "run", "--config", "/app/Caddyfile", "--adapter", "caddyfile", "--watch"]
|
|
@ -1,3 +1,22 @@
|
|||
# Environment variable defaults for node and sveltekit
|
||||
# NOTE: variables prefixed with 'PUBLIC_' are visible to clients!
|
||||
|
||||
PUBLIC_TITLE="Example TODO App"
|
||||
PUBLIC_DESCRIPTION="An example TODO app built with sveltekit and fastapi."
|
||||
PUBLIC_AUTHOR="John Doe"
|
||||
|
||||
HOST="0.0.0.0"
|
||||
PORT="3000"
|
||||
|
||||
# WARNING: only set these if running behind a trusted reverse proxy!
|
||||
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocol-header-and-host-header`.
|
||||
ORIGIN=http://localhost
|
||||
PROTOCOL_HEADER="x-forwarded-proto"
|
||||
HOST_HEADER="x-forwarded-host"
|
||||
|
||||
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-address-header-and-xff-depth`.
|
||||
ADDRESS_HEADER="True-Client-IP"
|
||||
|
||||
# Maximum request body size to accept in bytes.
|
||||
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-body-size-limit`.
|
||||
BODY_SIZE_LIMIT="1048576"
|
||||
|
|
|
@ -1 +1,6 @@
|
|||
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocol-header-and-host-header`.
|
||||
ORIGIN=http://localhost
|
||||
|
||||
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-address-header-and-xff-depth`.
|
||||
ADDRESS_HEADER="X-Forwarded-For"
|
||||
XFF_DEPTH="1"
|
||||
|
|
|
@ -4,21 +4,16 @@ This a sveltekit project, created with the npm creation script.
|
|||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
Start the development Docker stack by running
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
sudo docker-compose -f development.docker-compose.yml up --build --force-recreate --remove-orphans
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
To run the development Docker stack, run
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
sudo docker-compose -f production.docker-compose.yml up --build --force-recreate --remove-orphans --detach
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content={`${$page.url}`}>
|
||||
<meta property="og:description" content={description}>
|
||||
<meta property="og:image" content={ogImage}>
|
||||
<meta property="og:image" content={ogImageUrl}>
|
||||
<meta property="og:image:type" content="image/webp">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
|
@ -25,11 +25,11 @@
|
|||
import "../app.css";
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import ogImage from '/images/common/og-image.webp';
|
||||
|
||||
export let title = import.meta.env.PUBLIC_TITLE;
|
||||
export let description = import.meta.env.PUBLIC_DESCRIPTION;
|
||||
export let author = import.meta.env.PUBLIC_AUTHOR;
|
||||
|
||||
const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
|
|
|
@ -1,2 +1,6 @@
|
|||
<script>
|
||||
console.log(import.meta.env.MODE)
|
||||
</script>
|
||||
|
||||
<h1>Hello World!</h1>
|
||||
<p>This is a simple Todo-App as a tech stack template for new projects.</p>
|
||||
|
|
|
@ -1,11 +1,27 @@
|
|||
<script lang='ts'>
|
||||
let count = 0;
|
||||
|
||||
function handleClick() {
|
||||
count += 1;
|
||||
promise = getUsers();
|
||||
}
|
||||
|
||||
async function getUsers() {
|
||||
const res = await fetch("/api/users/");
|
||||
const json = await res.text();
|
||||
|
||||
if (res.ok) {
|
||||
return json;
|
||||
} else {
|
||||
throw new Error(json);
|
||||
}
|
||||
}
|
||||
let promise = getUsers();
|
||||
</script>
|
||||
|
||||
<h1>TODOs</h1>
|
||||
<p>Currently, there are {count} todo-items</p>
|
||||
<button on:click={handleClick}>Click Me</button>
|
||||
{#await promise}
|
||||
<p>Waiting</p>
|
||||
{:then users}
|
||||
<p>{users}</p>
|
||||
{:catch error}
|
||||
<p style="color: red">{error}</p>
|
||||
{/await}
|
||||
|
|
|
@ -6,7 +6,10 @@ const config = {
|
|||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
adapter: adapter({
|
||||
// Enable gzip and brotli compression of static assets and precompiled documents if not in development
|
||||
precompress: process.env.NODE_ENV !== 'development'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -4,4 +4,14 @@ import { defineConfig } from 'vite';
|
|||
export default defineConfig({
|
||||
envPrefix: 'PUBLIC_',
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
|
|
25
production.backend.Dockerfile
Normal file
25
production.backend.Dockerfile
Normal file
|
@ -0,0 +1,25 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:alpine
|
||||
|
||||
# Create non-root user
|
||||
ARG CUSTOM_UID
|
||||
ARG CUSTOM_GID
|
||||
ENV CUSTOM_USERNAME=backend
|
||||
ENV CUSTOM_GROUPNAME=backend
|
||||
RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \
|
||||
adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \
|
||||
mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app
|
||||
ENV PATH "$PATH:/home/${CUSTOM_GROUPNAME}/.local/bin"
|
||||
|
||||
# Copy source files
|
||||
WORKDIR /app
|
||||
COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} backend/ /app/
|
||||
|
||||
# Install dependencies
|
||||
USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000}
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Run ASGI server
|
||||
EXPOSE 3000/tcp
|
||||
ENTRYPOINT ["uvicorn", "todo.main:app", "--root-path", "/api", "--host", "0.0.0.0", "--port", "3000", "--access-log"]
|
88
production.docker-compose.yml
Normal file
88
production.docker-compose.yml
Normal file
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
todo-webserver:
|
||||
container_name: todo-webserver
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: production.webserver.Dockerfile
|
||||
args:
|
||||
CUSTOM_UID: 1000
|
||||
CUSTOM_GID: 1000
|
||||
networks:
|
||||
- proxy
|
||||
- todo
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.todo.entrypoints=https"
|
||||
- "traefik.http.routers.todo.rule=Host(`todo.example.com`)"
|
||||
- "traefik.http.routers.todo-secure.middlewares=default@file"
|
||||
- "traefik.http.routers.todo.tls=true"
|
||||
- "traefik.http.services.todo.loadbalancer.server.port=8000"
|
||||
- 'traefik.docker.network=proxy'
|
||||
todo-frontend:
|
||||
container_name: todo-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- todo-webserver
|
||||
build:
|
||||
context: .
|
||||
dockerfile: production.frontend.Dockerfile
|
||||
args:
|
||||
CUSTOM_UID: 1000
|
||||
CUSTOM_GID: 1000
|
||||
networks:
|
||||
- todo
|
||||
expose:
|
||||
- "3000"
|
||||
todo-backend:
|
||||
container_name: todo-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- todo-webserver
|
||||
- todo-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./production.backend.Dockerfile
|
||||
args:
|
||||
CUSTOM_UID: 1000
|
||||
CUSTOM_GID: 1000
|
||||
networks:
|
||||
- todo
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
APP_NAME: "TodoApp"
|
||||
ADMIN_EMAIL: "admin@example.com"
|
||||
DEBUG_MODE: "true"
|
||||
POSTGRES_HOST: "todo-db"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "todo"
|
||||
POSTGRES_USER: "todo"
|
||||
POSTGRES_PASSWORD: "todo"
|
||||
todo-db:
|
||||
image: postgres:alpine
|
||||
container_name: todo-db
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- todo
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- /srv/todo/data:/var/lib/postgresql/data
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
POSTGRES_DB: "todo"
|
||||
POSTGRES_USER: "todo"
|
||||
POSTGRES_PASSWORD: "todo"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
todo:
|
||||
external: false
|
||||
|
||||
...
|
29
production.frontend.Dockerfile
Normal file
29
production.frontend.Dockerfile
Normal file
|
@ -0,0 +1,29 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
# Install npm and nodejs
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# Create non-root user
|
||||
ARG CUSTOM_UID
|
||||
ARG CUSTOM_GID
|
||||
ENV CUSTOM_USERNAME=frontend
|
||||
ENV CUSTOM_GROUPNAME=frontend
|
||||
RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \
|
||||
adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \
|
||||
mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app
|
||||
|
||||
# Copy source files
|
||||
COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} frontend/ /app/
|
||||
|
||||
# Install dependencies and build app
|
||||
USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000}
|
||||
WORKDIR /app/
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
# Run node.js
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["node", "-r", "dotenv/config", "build"]
|
24
production.webserver.Dockerfile
Normal file
24
production.webserver.Dockerfile
Normal file
|
@ -0,0 +1,24 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
# Install caddy
|
||||
RUN apk add --no-cache caddy
|
||||
|
||||
# Create non-root user
|
||||
ARG CUSTOM_UID
|
||||
ARG CUSTOM_GID
|
||||
ENV CUSTOM_USERNAME=webserver
|
||||
ENV CUSTOM_GROUPNAME=webserver
|
||||
RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \
|
||||
adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \
|
||||
mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app
|
||||
|
||||
# Copy caddy config
|
||||
WORKDIR /app
|
||||
COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} Caddyfile /app/
|
||||
|
||||
# Run Caddy in development mode
|
||||
USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000}
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["caddy", "run", "--config", "/app/Caddyfile", "--adapter", "caddyfile"]
|
Loading…
Add table
Reference in a new issue