diff --git a/.woodpecker.yaml b/.woodpecker.yaml index 3d68271..f67c0b4 100644 --- a/.woodpecker.yaml +++ b/.woodpecker.yaml @@ -29,7 +29,7 @@ steps: path: include: [ backend/** ] settings: - registry: harbor-core.dev-tools.svc.cluster.local + registry: harbor.dvirlabs.com repo: my-apps/${CI_REPO_NAME}-backend dockerfile: backend/Dockerfile context: backend diff --git a/README.md b/README.md index 24d11e3..5617078 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,43 @@ oramap/ ├── backend/ │ ├── server.js # Express server │ ├── package.json # Backend dependencies +│ ├── Dockerfile # Backend container image │ └── 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/ -│ ├── index.html # Frontend HTML -│ ├── script.js # Frontend JavaScript -│ └── style.css # Styles -├── Dockerfile # Multi-stage Docker build -├── docker-compose.yml # Docker Compose configuration -└── .dockerignore # Docker ignore rules +│ ├── index.html # Shared frontend files +│ ├── script.js +│ └── style.css +├── Dockerfile # Monolith Docker build +├── docker-compose.yml # Monolith deployment +├── 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 ### Prerequisites @@ -79,40 +105,76 @@ Navigate to `http://localhost:3000` ## 🐳 Docker Deployment -### Using Docker Compose (Recommended) +### Monolith Mode (Recommended for Development) -1. **Build and start the application:** +1. **Build and start:** ```bash docker-compose up -d ``` -2. **View logs:** -```bash -docker-compose logs -f -``` +2. **Access:** http://localhost:3000 -3. **Stop the application:** +3. **Stop:** ```bash docker-compose down ``` -### Using Docker directly +### Microservices Mode (Recommended for Production) -1. **Build the image:** +1. **Build and start:** ```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 +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 ``` -3. **Check health:** +**Microservices:** ```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 ### Running in Development Mode diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ca82d5d --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4f8cae2 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/server-microservice.js b/backend/server-microservice.js new file mode 100644 index 0000000..cde82b5 --- /dev/null +++ b/backend/server-microservice.js @@ -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`); +}); diff --git a/docker-compose.microservices.yml b/docker-compose.microservices.yml new file mode 100644 index 0000000..1bdf9b6 --- /dev/null +++ b/docker-compose.microservices.yml @@ -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 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1e64f62 --- /dev/null +++ b/frontend/.dockerignore @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..cd6c5d2 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..dfd8092 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..86d16e9 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,26 @@ + + + + + 🗺️ Ora + + + + +
+

🗺️ Ora

+ +
+ +
+
+
+ + + + + + \ No newline at end of file diff --git a/frontend/public/logo-shay.png b/frontend/public/logo-shay.png new file mode 100644 index 0000000..75c5947 Binary files /dev/null and b/frontend/public/logo-shay.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..b2425c2 Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/script.js b/frontend/public/script.js new file mode 100644 index 0000000..2e213a5 --- /dev/null +++ b/frontend/public/script.js @@ -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: '© OpenStreetMap contributors © 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(`${record.family}
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 = ` + Logo + Shevach + `; + return div; + }, + + onRemove: function () { + // Nothing to clean up + } +}); + +L.control.logo({ position: 'bottomleft' }).addTo(map); \ No newline at end of file diff --git a/frontend/public/style.css b/frontend/public/style.css new file mode 100644 index 0000000..12ddc35 --- /dev/null +++ b/frontend/public/style.css @@ -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; + } \ No newline at end of file diff --git a/frontend/src/components/Search.jsx b/frontend/src/components/Search.jsx new file mode 100644 index 0000000..0dfe645 --- /dev/null +++ b/frontend/src/components/Search.jsx @@ -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 ( +
+ setQuery(e.target.value)} + /> + {results.length > 0 && ( +
+ {results.map((res, i) => ( +
{res}
+ ))} +
+ )} +
+ ); +} + +export default Search;