Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Git
.git
.gitignore
.github

# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
*.egg-info/
.eggs/
dist/
build/
.pytest_cache/
.python-version

# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
.yarn
client/node_modules/
client/dist/
client/.vite/
test-client/

# Environment
.env
.env.local
.env.*.local
.flaskenv

# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store

# Logs
*.log
logs/

# Database
*.db
*.sqlite
*.sqlite3
instance/

# Testing
.coverage
htmlcov/
.tox/

# Documentation
*.md
!README.md

# Misc
*.bak
*.tmp
.yamllint
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

__pycache__/
**__pycache__/
node_modules/
.env
client/dist
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/MapKitMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useMemo } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import '../styles/MapKitMap.css';
import ShuttleIcon from "./ShuttleIcon";
import config from "../ts/config";

import type { ShuttleRouteData, ShuttleStopData } from "../ts/types/route";
import type { VehicleInformationMap } from "../ts/types/vehicleLocation";
Expand Down Expand Up @@ -163,7 +164,7 @@ export default function MapKitMap({ routeData, displayVehicles = true, generateR

const pollLocation = async () => {
try {
const response = await fetch('/api/locations');
const response = await fetch(`${config.apiBaseUrl}/api/locations`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
Expand Down
3 changes: 2 additions & 1 deletion client/src/pages/Data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "../styles/Data.css"
import DataBoard from '../components/DataBoard';
import ShuttleRow from '../components/ShuttleRow';
import type { VehicleInformationMap } from '../ts/types/vehicleLocation';
import config from '../ts/config';

export default function Data() {

Expand All @@ -14,7 +15,7 @@ export default function Data() {

const fetchShuttleData = async () => {
try {
const response = await fetch('/api/today');
const response = await fetch(`${config.apiBaseUrl}/api/today`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
Expand Down
5 changes: 3 additions & 2 deletions client/src/ts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ const isStaging = import.meta.env.VITE_DEPLOY_MODE !== 'production';

const config = {
isStaging,
isDev: isStaging || import.meta.env.DEV
isDev: isStaging || import.meta.env.DEV,
apiBaseUrl: import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'
};

export default config;
export default config;
12 changes: 6 additions & 6 deletions data/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def match_shuttles_to_schedules(cls):
# Determine day from first timestamp
required_cols = {"vehicle_id", "timestamp", "route_name"}

if at_stops.empty or not required_cols.issubset(at_stops.columns):
if not at_stops or not required_cols.issubset(at_stops.columns):
logger.warning("at_stops is missing required data returning empty match.")
return {}

Expand Down Expand Up @@ -139,15 +139,15 @@ def match_shuttles_to_schedules(cls):
# Precompute minute-aligned timestamps
at_stops['minute'] = at_stops['timestamp'].dt.floor('min')


# Group logs
shuttle_groups = {k: v for k, v in at_stops.groupby('vehicle_id')}



# Build cost matrix
for i, shuttle in enumerate(shuttles):

logs = shuttle_groups.get(shuttle)
if logs is None or logs.empty:
W[i] = 1 #No data for shuttle
Expand Down Expand Up @@ -176,6 +176,6 @@ def match_shuttles_to_schedules(cls):
cache.set("schedule_entries", result, timeout=3600)

return result

if __name__ == "__main__":
result = Schedule.match_shuttles_to_schedules()
result = Schedule.match_shuttles_to_schedules()
90 changes: 90 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-shubble}
POSTGRES_USER: ${POSTGRES_USER:-shubble}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shubble}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shubble"]
interval: 10s
timeout: 5s
retries: 5

redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5

backend:
build:
context: .
dockerfile: docker/Dockerfile.backend
ports:
- "${BACKEND_PORT:-8000}:8000"
extra_hosts:
- "localhost:host-gateway"
environment:
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL:-postgresql://shubble:shubble@postgres:5432/shubble}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
FLASK_ENV: ${FLASK_ENV:-development}
FLASK_DEBUG: ${FLASK_DEBUG:-true}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
API_KEY: ${API_KEY:-}
SAMSARA_SECRET: ${SAMSARA_SECRET:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped

worker:
build:
context: .
dockerfile: docker/Dockerfile.worker
extra_hosts:
- "localhost:host-gateway"
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://shubble:shubble@postgres:5432/shubble}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
FLASK_ENV: ${FLASK_ENV:-development}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
API_KEY: ${API_KEY:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
backend:
condition: service_started
restart: unless-stopped

frontend:
build:
context: .
dockerfile: docker/Dockerfile.frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
environment:
VITE_BACKEND_URL: ${VITE_BACKEND_URL:-http://localhost:8000}
depends_on:
- backend
restart: unless-stopped

volumes:
postgres_data:
redis_data:
42 changes: 42 additions & 0 deletions docker/Dockerfile.backend
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Backend Dockerfile for Shubble Flask API
FROM python:3.12-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*

# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt

# Copy application code
COPY server/ ./server/
COPY data/ ./data/
COPY migrations/ ./migrations/
COPY shubble.py .

# Create non-root user
RUN useradd -m -u 1000 shubble && chown -R shubble:shubble /app
USER shubble

# Expose port
EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/api/locations', timeout=5)"

# Run database migrations and start gunicorn
CMD flask --app server:create_app db upgrade && \
gunicorn shubble:app \
--bind 0.0.0.0:8000 \
--workers 2 \
--threads 4 \
--timeout 120 \
--log-level ${LOG_LEVEL:-info} \
--access-logfile - \
--error-logfile -
79 changes: 79 additions & 0 deletions docker/Dockerfile.frontend
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Frontend Dockerfile for Shubble
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files
COPY package*.json ./
COPY client/package*.json ./client/

# Install dependencies
RUN npm ci

# Copy source files and data
COPY client/ ./client/
COPY data/ ./data/
COPY vite.config.ts ./
COPY tsconfig*.json ./

# Build the application with a placeholder that will be replaced at runtime
# This allows the backend URL to be configured via environment variable
ENV VITE_BACKEND_URL=__VITE_BACKEND_URL__
RUN npm run build

# Production stage with nginx
FROM nginx:alpine

# Install gettext for envsubst
RUN apk add --no-cache gettext

# Copy built files to nginx
COPY --from=builder /app/client/dist /usr/share/nginx/html

# Copy nginx configuration
COPY <<EOF /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;

# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

# Handle SPA routing
location / {
try_files \$uri \$uri/ /index.html;
}

# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
EOF

# Create entrypoint script to substitute environment variables at runtime
COPY <<'EOF' /docker-entrypoint.sh
#!/bin/sh
set -e

# Replace __VITE_BACKEND_URL__ placeholder with actual environment variable value
find /usr/share/nginx/html -type f -name "*.js" -exec sed -i "s|__VITE_BACKEND_URL__|${VITE_BACKEND_URL:-http://localhost:8000}|g" {} \;

# Start nginx
exec nginx -g "daemon off;"
EOF

RUN chmod +x /docker-entrypoint.sh

EXPOSE 80

CMD ["/docker-entrypoint.sh"]
Loading