Add microservices architecture with separate frontend/backend
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- Created backend/Dockerfile for Express API server
- Created frontend/Dockerfile with Nginx for static files
- Added nginx.conf to proxy /api/* to backend
- Created docker-compose.microservices.yml for multi-container setup
- Added .dockerignore files for both frontend and backend
- Updated .woodpecker.yaml to fix registry URL and use separate Dockerfiles
- Added CORS support to backend for microservices mode
- Updated README with dual-mode deployment instructions
- Frontend copies to frontend/public/ for Nginx serving
- CI/CD pipeline now builds separate images for frontend and backend
This commit is contained in:
dvirlabs 2026-03-24 09:41:51 +02:00
parent 5a7585f755
commit 15b8334f2a
15 changed files with 599 additions and 20 deletions

View File

@ -29,7 +29,7 @@ steps:
path: path:
include: [ backend/** ] include: [ backend/** ]
settings: settings:
registry: harbor-core.dev-tools.svc.cluster.local registry: harbor.dvirlabs.com
repo: my-apps/${CI_REPO_NAME}-backend repo: my-apps/${CI_REPO_NAME}-backend
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
context: backend context: backend

100
README.md
View File

@ -44,17 +44,43 @@ oramap/
├── backend/ ├── backend/
│ ├── server.js # Express server │ ├── server.js # Express server
│ ├── package.json # Backend dependencies │ ├── package.json # Backend dependencies
│ ├── Dockerfile # Backend container image
│ └── data/ │ └── data/
│ └── families.json # Family location data │ └── families.json # Family location data
├── frontend/
│ ├── Dockerfile # Frontend Nginx container
│ ├── nginx.conf # Nginx configuration
│ └── public/
│ ├── index.html # Frontend HTML
│ ├── script.js # Frontend JavaScript
│ └── style.css # Styles
├── public/ ├── public/
│ ├── index.html # Frontend HTML │ ├── index.html # Shared frontend files
│ ├── script.js # Frontend JavaScript │ ├── script.js
│ └── style.css # Styles │ └── style.css
├── Dockerfile # Multi-stage Docker build ├── Dockerfile # Monolith Docker build
├── docker-compose.yml # Docker Compose configuration ├── docker-compose.yml # Monolith deployment
└── .dockerignore # Docker ignore rules ├── docker-compose.microservices.yml # Microservices deployment
├── .woodpecker.yaml # CI/CD pipeline config
└── .dockerignore # Docker ignore rules
``` ```
## 🏗️ Architecture
The application supports two deployment modes:
### 1. **Monolith Mode** (Simple)
- Single container with Express serving both API and static files
- Best for: Development, simple deployments
- Use: `docker-compose up`
### 2. **Microservices Mode** (Production)
- **Frontend**: Nginx serving static files (Port 80)
- **Backend**: Express API server (Internal Port 3000)
- Nginx proxies `/api/*` requests to backend
- Best for: Production, scalability, CI/CD pipelines
- Use: `docker-compose -f docker-compose.microservices.yml up`
## 🚀 Getting Started ## 🚀 Getting Started
### Prerequisites ### Prerequisites
@ -79,40 +105,76 @@ Navigate to `http://localhost:3000`
## 🐳 Docker Deployment ## 🐳 Docker Deployment
### Using Docker Compose (Recommended) ### Monolith Mode (Recommended for Development)
1. **Build and start the application:** 1. **Build and start:**
```bash ```bash
docker-compose up -d docker-compose up -d
``` ```
2. **View logs:** 2. **Access:** http://localhost:3000
```bash
docker-compose logs -f
```
3. **Stop the application:** 3. **Stop:**
```bash ```bash
docker-compose down docker-compose down
``` ```
### Using Docker directly ### Microservices Mode (Recommended for Production)
1. **Build the image:** 1. **Build and start:**
```bash ```bash
docker build -t oramap:latest . docker-compose -f docker-compose.microservices.yml up -d
``` ```
2. **Run the container:** 2. **Access:** http://localhost (Port 80)
3. **View logs:**
```bash ```bash
docker logs oramap-frontend
docker logs oramap-backend
```
4. **Stop:**
```bash
docker-compose -f docker-compose.microservices.yml down
```
### Using Docker directly
**Monolith:**
```bash
docker build -t oramap:latest .
docker run -d -p 3000:3000 --name oramap-app oramap:latest docker run -d -p 3000:3000 --name oramap-app oramap:latest
``` ```
3. **Check health:** **Microservices:**
```bash ```bash
docker ps # Build images
docker build -t oramap-backend -f backend/Dockerfile backend/
docker build -t oramap-frontend -f frontend/Dockerfile frontend/
# Run with network
docker network create oramap-network
docker run -d --name oramap-backend --network oramap-network oramap-backend
docker run -d --name oramap-frontend --network oramap-network -p 80:80 oramap-frontend
``` ```
## 🔄 CI/CD Pipeline
The project includes a Woodpecker CI configuration (`.woodpecker.yaml`) that automatically:
1. **Builds** separate frontend and backend Docker images on push
2. **Tags** images with branch name and commit SHA
3. **Pushes** to Harbor registry (`harbor.dvirlabs.com`)
4. **Updates** Kubernetes manifests with new image tags
5. **Triggers** on changes to `frontend/**` or `backend/**` paths
### Pipeline Steps:
- `build-frontend`: Build and push frontend Nginx image
- `build-backend`: Build and push backend API image
- `update-values-frontend`: Update frontend image tag in values.yaml
- `update-values-backend`: Update backend image tag in values.yaml
## 💻 Development ## 💻 Development
### Running in Development Mode ### Running in Development Mode

28
backend/.dockerignore Normal file
View File

@ -0,0 +1,28 @@
# Dependencies
node_modules/
npm-debug.log*
# Environment
.env*
# Build artifacts
dist/
# IDE
.vscode/
.idea/
*.swp
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Documentation
*.md
# Data not needed in image (using volume or copy)
# Keep data/ folder as it's needed

30
backend/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# Backend Dockerfile for Ora Map
FROM node:20-alpine AS base
# Production stage
FROM base AS production
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies
RUN npm install --production
# Copy application files
COPY . .
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "server.js"]

View File

@ -0,0 +1,35 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const app = express();
const families = require('./data/families.json');
// Enable CORS for frontend
app.use(cors());
// Serve static files from the public directory (for standalone mode)
if (process.env.SERVE_STATIC === 'true') {
app.use(express.static(path.join(__dirname, '../public')));
}
// API endpoint for family search
app.get('/api/search', (req, res) => {
const query = req.query.family?.toLowerCase();
if (!query) {
return res.json([]);
}
const matches = families.filter(fam => fam.family.toLowerCase().includes(query));
res.json(matches);
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const port = process.env.PORT || 3000;
app.listen(port, '0.0.0.0', () => {
console.log(`🗺️ Ora Map Backend API running at http://localhost:${port}`);
console.log(`📍 Search endpoint: http://localhost:${port}/api/search`);
console.log(`💚 Health endpoint: http://localhost:${port}/api/health`);
});

View File

@ -0,0 +1,48 @@
version: '3.8'
services:
# Backend API Service
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: oramap-backend:latest
container_name: oramap-backend
environment:
- NODE_ENV=production
- PORT=3000
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
networks:
- oramap-network
# Frontend Nginx Service
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: oramap-frontend:latest
container_name: oramap-frontend
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
networks:
- oramap-network
networks:
oramap-network:
driver: bridge

31
frontend/.dockerignore Normal file
View File

@ -0,0 +1,31 @@
# Frontend .dockerignore
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Source files (not needed for production)
src/
# Build files
.git/
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
Dockerfile
.dockerignore
# Documentation
README.md
*.md

18
frontend/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
# Frontend Dockerfile for Ora Map (Static files with Nginx)
FROM nginx:alpine
# Copy static files to nginx html directory
COPY public/ /usr/share/nginx/html/
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

34
frontend/nginx.conf Normal file
View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Main location
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>🗺️ Ora</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
</head>
<body>
<header>
<h1>🗺️ Ora</h1>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Enter family name..." />
<button onclick="searchFamily()">Search</button>
</div>
</header>
<main>
<div id="map"></div>
</main>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js/dist/fuse.min.js"></script>
<script src="script.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

124
frontend/public/script.js Normal file
View File

@ -0,0 +1,124 @@
let map = L.map('map').setView([15.5527, 48.5164], 6);
// Define the two tile layers
const voyager = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19
});
const openStreetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: 'Map data © OpenStreetMap contributors',
maxZoom: 19
});
// Add the default layer (Voyager)
voyager.addTo(map);
// Group the layers for switching
const baseMaps = {
"English Map (CartoDB Voyager)": voyager,
"Original Map (OpenStreetMap)": openStreetMap
};
// Add the layer control to the map
L.control.layers(baseMaps).addTo(map);
async function searchFamily() {
const familyName = document.getElementById('searchInput').value.trim();
if (!familyName) {
alert('Please enter a family name.');
return;
}
try {
const response = await fetch(`/api/search?family=${encodeURIComponent(familyName)}`);
const familyResult = await response.json();
if (!familyResult.length) {
alert('No matching family found.');
return;
}
clearMarkers();
familyResult.forEach(record => {
L.marker([record.lat, record.lng]).addTo(map)
.bindPopup(`<strong>${record.family}</strong><br>City: ${record.city}`)
.openPopup();
});
const first = familyResult[0];
map.setView([first.lat, first.lng], 7);
} catch (error) {
console.error('Search error:', error);
alert('Something went wrong while searching. Please try again later.');
}
}
function clearMarkers() {
map.eachLayer(layer => {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
}
const familyNames = [
"Kafe (קאפח)", "Shiheb (שחב-שבח)", "Eraki (עראקי)", "Salumi (סלומי-שלומי)",
"Afgin (עפג'ין)", "Uzeyri (עזירי-עוזרי)"
// Add more families here if you like
];
// Initialize Fuse.js
const fuse = new Fuse(familyNames, {
includeScore: true,
threshold: 0.4 // Lower = stricter matching
});
const searchInput = document.getElementById('searchInput');
const suggestionsBox = document.createElement('div');
suggestionsBox.classList.add('suggestions');
searchInput.parentNode.style.position = 'relative'; // Make parent relative
searchInput.parentNode.appendChild(suggestionsBox);
searchInput.addEventListener('input', () => {
const value = searchInput.value.trim();
suggestionsBox.innerHTML = '';
if (!value) return;
const results = fuse.search(value);
results.forEach(result => {
const option = document.createElement('div');
option.textContent = result.item;
option.onclick = () => {
searchInput.value = result.item;
suggestionsBox.innerHTML = '';
};
suggestionsBox.appendChild(option);
});
});
L.control.logo = function (opts) {
return new L.Control.Logo(opts);
};
L.Control.Logo = L.Control.extend({
onAdd: function () {
const div = L.DomUtil.create('div', 'custom-logo');
div.innerHTML = `
<img src="logo.png" alt="Logo" style="height: 40px; vertical-align: middle;">
<span style="margin-left: 8px; font-weight: bold; color: #333;">Shevach</span>
`;
return div;
},
onRemove: function () {
// Nothing to clean up
}
});
L.control.logo({ position: 'bottomleft' }).addTo(map);

98
frontend/public/style.css Normal file
View File

@ -0,0 +1,98 @@
/* Reset some basic styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f4f4f4;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header styles */
header {
background-color: #2c3e50;
padding: 20px;
text-align: center;
color: white;
}
h1 {
margin-bottom: 10px;
}
.search-bar {
margin-top: 10px;
}
.search-bar input {
padding: 10px;
width: 250px;
border: none;
border-radius: 5px;
margin-right: 8px;
}
.search-bar button {
padding: 10px 15px;
border: none;
border-radius: 5px;
background-color: #3498db;
color: white;
cursor: pointer;
font-weight: bold;
}
.suggestions {
background: #363251;
border: 1px solid #ccc;
position: absolute;
top: 110%; /* Just below the input */
left: 48%;
transform: translateX(-50%);
max-width: fit-content;
min-width: 200px; /* Optional: minimum size */
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
transition: all 0.2s ease-in-out;
}
.suggestions div {
padding: 10px;
cursor: pointer;
}
.suggestions div:hover {
background-color: #95a1a8;
}
.custom-logo {
background: rgba(255, 255, 255, 0.9);
padding: 6px 10px;
border-radius: 8px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
font-family: sans-serif;
}
/* Map container */
#map {
flex-grow: 1;
height: 80vh;
width: 100%;
}
/* Main container */
main {
flex: 1;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,45 @@
import { useState, useEffect } from 'react';
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (query.trim() === '') {
setResults([]);
return;
}
const fetchSuggestions = async () => {
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data);
} catch (err) {
console.error('Failed to fetch suggestions:', err);
}
};
fetchSuggestions();
}, [query]);
return (
<div className="search-container">
<input
type="text"
placeholder="Search..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
{results.length > 0 && (
<div className="autocomplete-box">
{results.map((res, i) => (
<div key={i} className="suggestion">{res}</div>
))}
</div>
)}
</div>
);
}
export default Search;