diff --git a/Dockerfile b/Dockerfile
index 422e87f..00c5015 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,53 @@
-FROM eclipse-temurin:21-jdk-alpine AS builder
-WORKDIR /app
-COPY pom.xml .
-COPY src ./src
-RUN apk add --no-cache maven && mvn clean package -DskipTests
+# ============================================================
+# Stage 1: Build
+# ============================================================
+FROM eclipse-temurin:21-jdk-alpine AS build
-FROM eclipse-temurin:21-jre-alpine
-WORKDIR /app
+# Install Maven
+RUN apk add --no-cache maven
+
+WORKDIR /workspace
+
+# Copy POM first to cache dependency layer
+COPY backend/pom.xml .
+
+# Download dependencies (cached unless pom.xml changes)
+RUN mvn dependency:go-offline -B --no-transfer-progress
+
+# Copy source code and build the fat JAR, skipping tests
+COPY backend/src src
+RUN mvn package -DskipTests -B --no-transfer-progress
+
+# ============================================================
+# Stage 2: Runtime
+# ============================================================
+FROM eclipse-temurin:21-jre-alpine AS runtime
+
+# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
-COPY --from=builder /app/target/*.jar app.jar
-RUN chown appuser:appgroup app.jar
+
+WORKDIR /app
+
+# Copy the built JAR from the build stage
+COPY --from=build /workspace/target/*.jar app.jar
+
+# Ensure the app directory is owned by the non-root user
+RUN chown -R appuser:appgroup /app
+
USER appuser
+
+# Expose application port and management port
EXPOSE 8080
-ENTRYPOINT ["java", "-jar", "app.jar"]
+EXPOSE 8081
+
+# Health check – relies on Spring Actuator management port
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD wget -qO- http://localhost:8081/actuator/health || exit 1
+
+# JVM tuning flags for containers (respects cgroup memory limits)
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:+ExitOnOutOfMemoryError \
+ -Djava.security.egd=file:/dev/./urandom"
+
+ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
diff --git a/Dockerfile.orig b/Dockerfile.orig
new file mode 100644
index 0000000..06d3ddf
--- /dev/null
+++ b/Dockerfile.orig
@@ -0,0 +1,52 @@
+# ============================================================
+# Stage 1: Build
+# ============================================================
+FROM eclipse-temurin:21-jdk-alpine AS build
+
+# Install Maven
+RUN apk add --no-cache maven
+
+WORKDIR /workspace
+
+# Copy POM first to cache dependency layer
+COPY backend/pom.xml .
+
+# Download dependencies (cached unless pom.xml changes)
+RUN mvn dependency:go-offline -B --no-transfer-progress
+
+# Copy source code and build the fat JAR, skipping tests
+COPY backend/src src
+RUN mvn package -DskipTests -B --no-transfer-progress
+
+# ============================================================
+# Stage 2: Runtime
+# ============================================================
+FROM eclipse-temurin:21-jre-alpine AS runtime
+
+# Create a non-root user for security
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+WORKDIR /app
+
+# Copy the built JAR from the build stage
+COPY --from=build /workspace/target/*.jar app.jar
+
+# Ensure the app directory is owned by the non-root user
+RUN chown -R appuser:appgroup /app
+
+USER appuser
+
+# Expose application port
+EXPOSE 8080
+
+# Health check – relies on Spring Actuator
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD wget -qO- http://localhost:8080/actuator/health || exit 1
+
+# JVM tuning flags for containers (respects cgroup memory limits)
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:+ExitOnOutOfMemoryError \
+ -Djava.security.egd=file:/dev/./urandom"
+
+ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..34457c9
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,52 @@
+version: "3.9"
+
+# Production overrides – extend the base docker-compose.yml with:
+# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
+
+services:
+
+ # ── Spring Boot application ───────────────────────────────
+ app:
+ environment:
+ SPRING_PROFILES_ACTIVE: prod
+ SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
+ SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
+ SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_ACCESS_TOKEN_EXPIRATION_MS: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:-900000}
+ JWT_REFRESH_TOKEN_EXPIRATION_MS: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:-604800000}
+ CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
+ JAVA_OPTS: >-
+ -XX:+UseContainerSupport
+ -XX:MaxRAMPercentage=75.0
+ -XX:+ExitOnOutOfMemoryError
+ -Djava.security.egd=file:/dev/./urandom
+ restart: always
+ deploy:
+ resources:
+ limits:
+ cpus: "1.0"
+ memory: 512M
+
+ # ── MariaDB ───────────────────────────────────────────────
+ mariadb:
+ environment:
+ MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
+ MARIADB_DATABASE: ${DB_NAME:-jobtracker}
+ MARIADB_USER: ${SPRING_DATASOURCE_USERNAME}
+ MARIADB_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
+ ports: [] # do not expose DB port in production
+ restart: always
+
+ # ── Prometheus ────────────────────────────────────────────
+ prometheus:
+ ports: [] # do not expose Prometheus externally in production
+ restart: always
+
+ # ── Grafana ───────────────────────────────────────────────
+ grafana:
+ environment:
+ GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
+ GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3000}
+ restart: always
diff --git a/docker-compose.yml b/docker-compose.yml
index bdf6d43..037aefc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,16 +1,44 @@
-version: '3.8'
+version: "3.9"
services:
- db:
- image: mariadb:11.2
- container_name: jobtracker-db
+
+ # ── Spring Boot application ───────────────────────────────
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: job-tracker-app
+ ports:
+ - "8080:8080"
environment:
- MYSQL_ROOT_PASSWORD: root
- MYSQL_DATABASE: jobtracker
- MYSQL_USER: jobtracker
- MYSQL_PASSWORD: jobtracker
+ SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true
+ SPRING_DATASOURCE_USERNAME: root
+ SPRING_DATASOURCE_PASSWORD: root
+ # WARNING: Replace this secret before any production use. This value is for development only.
+ JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment
+ CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173"
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── MariaDB ───────────────────────────────────────────────
+ mariadb:
+ image: mariadb:11
+ container_name: job-tracker-mariadb
ports:
- "3306:3306"
+ environment:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: jobtracker
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
@@ -18,24 +46,61 @@ services:
interval: 10s
timeout: 5s
retries: 5
+ start_period: 30s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
- app:
- build: .
- container_name: jobtracker-app
+ # ── Prometheus ────────────────────────────────────────────
+ prometheus:
+ image: prom/prometheus:v2.54.1
+ container_name: job-tracker-prometheus
ports:
- - "8080:8080"
+ - "9090:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--web.console.libraries=/usr/share/prometheus/console_libraries"
+ - "--web.console.templates=/usr/share/prometheus/consoles"
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 15s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Grafana ───────────────────────────────────────────────
+ grafana:
+ image: grafana/grafana:10.4.8
+ container_name: job-tracker-grafana
+ ports:
+ - "3000:3000"
environment:
- DB_URL: jdbc:mariadb://db:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true
- DB_USERNAME: jobtracker
- DB_PASSWORD: jobtracker
- JWT_SECRET: ${JWT_SECRET:-ChangeThisToASecureRandomSecretKeyInProductionAtLeast256BitsLong}
- JWT_ACCESS_TOKEN_EXPIRATION_MS: 900000
- JWT_REFRESH_TOKEN_EXPIRATION_MS: 604800000
- CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: admin
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
+ - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
+ - ./grafana/dashboards:/etc/grafana/dashboards:ro
depends_on:
- db:
+ prometheus:
condition: service_healthy
+ networks:
+ - job-tracker-net
restart: unless-stopped
volumes:
mariadb_data:
+ prometheus_data:
+ grafana_data:
+
+networks:
+ job-tracker-net:
+ driver: bridge
diff --git a/docker-compose.yml.orig b/docker-compose.yml.orig
new file mode 100644
index 0000000..5600e15
--- /dev/null
+++ b/docker-compose.yml.orig
@@ -0,0 +1,99 @@
+version: "3.9"
+
+services:
+
+ # ── Spring Boot application ───────────────────────────────
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: job-tracker-app
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true
+ SPRING_DATASOURCE_USERNAME: root
+ SPRING_DATASOURCE_PASSWORD: root
+ # WARNING: Replace this secret before any production use. This value is for development only.
+ JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment
+ CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173"
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── MariaDB ───────────────────────────────────────────────
+ mariadb:
+ image: mariadb:11
+ container_name: job-tracker-mariadb
+ ports:
+ - "3306:3306"
+ environment:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: jobtracker
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Prometheus ────────────────────────────────────────────
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: job-tracker-prometheus
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--web.console.libraries=/usr/share/prometheus/console_libraries"
+ - "--web.console.templates=/usr/share/prometheus/consoles"
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Grafana ───────────────────────────────────────────────
+ grafana:
+ image: grafana/grafana:latest
+ container_name: job-tracker-grafana
+ ports:
+ - "3000:3000"
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: admin
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
+ - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
+ - ./grafana/dashboards:/etc/grafana/dashboards:ro
+ depends_on:
+ - prometheus
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+volumes:
+ mariadb_data:
+ prometheus_data:
+ grafana_data:
+
+networks:
+ job-tracker-net:
+ driver: bridge
diff --git a/docker-compose_BACKUP_20088.yml b/docker-compose_BACKUP_20088.yml
new file mode 100644
index 0000000..5600e15
--- /dev/null
+++ b/docker-compose_BACKUP_20088.yml
@@ -0,0 +1,99 @@
+version: "3.9"
+
+services:
+
+ # ── Spring Boot application ───────────────────────────────
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: job-tracker-app
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true
+ SPRING_DATASOURCE_USERNAME: root
+ SPRING_DATASOURCE_PASSWORD: root
+ # WARNING: Replace this secret before any production use. This value is for development only.
+ JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment
+ CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173"
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── MariaDB ───────────────────────────────────────────────
+ mariadb:
+ image: mariadb:11
+ container_name: job-tracker-mariadb
+ ports:
+ - "3306:3306"
+ environment:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: jobtracker
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Prometheus ────────────────────────────────────────────
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: job-tracker-prometheus
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--web.console.libraries=/usr/share/prometheus/console_libraries"
+ - "--web.console.templates=/usr/share/prometheus/consoles"
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Grafana ───────────────────────────────────────────────
+ grafana:
+ image: grafana/grafana:latest
+ container_name: job-tracker-grafana
+ ports:
+ - "3000:3000"
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: admin
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
+ - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
+ - ./grafana/dashboards:/etc/grafana/dashboards:ro
+ depends_on:
+ - prometheus
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+volumes:
+ mariadb_data:
+ prometheus_data:
+ grafana_data:
+
+networks:
+ job-tracker-net:
+ driver: bridge
diff --git a/docker-compose_BASE_20088.yml b/docker-compose_BASE_20088.yml
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose_LOCAL_20088.yml b/docker-compose_LOCAL_20088.yml
new file mode 100644
index 0000000..5600e15
--- /dev/null
+++ b/docker-compose_LOCAL_20088.yml
@@ -0,0 +1,99 @@
+version: "3.9"
+
+services:
+
+ # ── Spring Boot application ───────────────────────────────
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: job-tracker-app
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true
+ SPRING_DATASOURCE_USERNAME: root
+ SPRING_DATASOURCE_PASSWORD: root
+ # WARNING: Replace this secret before any production use. This value is for development only.
+ JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment
+ CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173"
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── MariaDB ───────────────────────────────────────────────
+ mariadb:
+ image: mariadb:11
+ container_name: job-tracker-mariadb
+ ports:
+ - "3306:3306"
+ environment:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: jobtracker
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Prometheus ────────────────────────────────────────────
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: job-tracker-prometheus
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--web.console.libraries=/usr/share/prometheus/console_libraries"
+ - "--web.console.templates=/usr/share/prometheus/consoles"
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+ # ── Grafana ───────────────────────────────────────────────
+ grafana:
+ image: grafana/grafana:latest
+ container_name: job-tracker-grafana
+ ports:
+ - "3000:3000"
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: admin
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
+ - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
+ - ./grafana/dashboards:/etc/grafana/dashboards:ro
+ depends_on:
+ - prometheus
+ networks:
+ - job-tracker-net
+ restart: unless-stopped
+
+volumes:
+ mariadb_data:
+ prometheus_data:
+ grafana_data:
+
+networks:
+ job-tracker-net:
+ driver: bridge
diff --git a/docker-compose_REMOTE_20088.yml b/docker-compose_REMOTE_20088.yml
new file mode 100644
index 0000000..bdf6d43
--- /dev/null
+++ b/docker-compose_REMOTE_20088.yml
@@ -0,0 +1,41 @@
+version: '3.8'
+
+services:
+ db:
+ image: mariadb:11.2
+ container_name: jobtracker-db
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: jobtracker
+ MYSQL_USER: jobtracker
+ MYSQL_PASSWORD: jobtracker
+ ports:
+ - "3306:3306"
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ app:
+ build: .
+ container_name: jobtracker-app
+ ports:
+ - "8080:8080"
+ environment:
+ DB_URL: jdbc:mariadb://db:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true
+ DB_USERNAME: jobtracker
+ DB_PASSWORD: jobtracker
+ JWT_SECRET: ${JWT_SECRET:-ChangeThisToASecureRandomSecretKeyInProductionAtLeast256BitsLong}
+ JWT_ACCESS_TOKEN_EXPIRATION_MS: 900000
+ JWT_REFRESH_TOKEN_EXPIRATION_MS: 604800000
+ CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: unless-stopped
+
+volumes:
+ mariadb_data:
diff --git a/grafana/dashboards/job-tracker-dashboard.json b/grafana/dashboards/job-tracker-dashboard.json
new file mode 100644
index 0000000..9a302e7
--- /dev/null
+++ b/grafana/dashboards/job-tracker-dashboard.json
@@ -0,0 +1,82 @@
+{
+ "title": "Job Apply Tracker",
+ "uid": "job-tracker-overview",
+ "schemaVersion": 38,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-1h", "to": "now" },
+ "panels": [
+ {
+ "id": 1,
+ "type": "timeseries",
+ "title": "HTTP Request Rate (req/s)",
+ "gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 },
+ "targets": [
+ {
+ "expr": "sum(rate(http_server_requests_seconds_count{application=\"job-tracker\"}[1m])) by (uri, method, status)",
+ "legendFormat": "{{method}} {{uri}} {{status}}"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "type": "timeseries",
+ "title": "HTTP Response Time (p99 ms)",
+ "gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{application=\"job-tracker\"}[1m])) by (le, uri)) * 1000",
+ "legendFormat": "p99 {{uri}}"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "type": "timeseries",
+ "title": "HTTP Error Rate (4xx + 5xx)",
+ "gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
+ "targets": [
+ {
+ "expr": "sum(rate(http_server_requests_seconds_count{application=\"job-tracker\", status=~\"4..|5..\"}[1m])) by (status)",
+ "legendFormat": "HTTP {{status}}"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "type": "timeseries",
+ "title": "JVM Memory Used (MB)",
+ "gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
+ "targets": [
+ {
+ "expr": "jvm_memory_used_bytes{application=\"job-tracker\"} / 1048576",
+ "legendFormat": "{{area}} {{id}}"
+ }
+ ]
+ },
+ {
+ "id": 5,
+ "type": "timeseries",
+ "title": "CPU Usage",
+ "gridPos": { "x": 0, "y": 16, "w": 12, "h": 8 },
+ "targets": [
+ {
+ "expr": "process_cpu_usage{application=\"job-tracker\"}",
+ "legendFormat": "CPU usage"
+ }
+ ]
+ },
+ {
+ "id": 6,
+ "type": "timeseries",
+ "title": "Active DB Connections",
+ "gridPos": { "x": 12, "y": 16, "w": 12, "h": 8 },
+ "targets": [
+ {
+ "expr": "hikaricp_connections_active{application=\"job-tracker\"}",
+ "legendFormat": "Active connections"
+ }
+ ]
+ }
+ ]
+}
diff --git a/grafana/provisioning/dashboards/dashboard.yml b/grafana/provisioning/dashboards/dashboard.yml
new file mode 100644
index 0000000..f588ca9
--- /dev/null
+++ b/grafana/provisioning/dashboards/dashboard.yml
@@ -0,0 +1,11 @@
+apiVersion: 1
+
+providers:
+ - name: 'Default'
+ orgId: 1
+ folder: ''
+ type: file
+ disableDeletion: false
+ updateIntervalSeconds: 10
+ options:
+ path: /etc/grafana/dashboards
diff --git a/grafana/provisioning/datasources/datasource.yml b/grafana/provisioning/datasources/datasource.yml
new file mode 100644
index 0000000..1a57b69
--- /dev/null
+++ b/grafana/provisioning/datasources/datasource.yml
@@ -0,0 +1,9 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ editable: true
diff --git a/pom.xml b/pom.xml
index c24b353..cb7c049 100644
--- a/pom.xml
+++ b/pom.xml
@@ -86,6 +86,25 @@
2.5.0
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
+
+
+ net.logstash.logback
+ logstash-logback-encoder
+ 7.4
+
+
org.springframework.boot
diff --git a/prometheus.yml b/prometheus.yml
new file mode 100644
index 0000000..7d5765e
--- /dev/null
+++ b/prometheus.yml
@@ -0,0 +1,9 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ - job_name: 'spring-app'
+ metrics_path: '/actuator/prometheus'
+ static_configs:
+ - targets: ['app:8081']
diff --git a/src/main/java/com/jobtracker/config/OpenApiConfig.java b/src/main/java/com/jobtracker/config/OpenApiConfig.java
index eeeb5a5..3d8b1c1 100644
--- a/src/main/java/com/jobtracker/config/OpenApiConfig.java
+++ b/src/main/java/com/jobtracker/config/OpenApiConfig.java
@@ -16,8 +16,8 @@ public OpenAPI openAPI() {
final String securitySchemeName = "bearerAuth";
return new OpenAPI()
.info(new Info()
- .title("Job Tracker API")
- .description("REST API for Job Application Tracker PWA")
+ .title("Job Apply Tracker API")
+ .description("API for tracking job applications")
.version("1.0.0"))
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
.components(new Components()
diff --git a/src/main/java/com/jobtracker/config/RequestLoggingFilter.java b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
new file mode 100644
index 0000000..bedc5d1
--- /dev/null
+++ b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
@@ -0,0 +1,54 @@
+package com.jobtracker.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.lang.NonNull;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+public class RequestLoggingFilter extends OncePerRequestFilter {
+
+ private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
+
+ @Override
+ protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
+ // Skip logging for actuator endpoints to avoid log spam from health probes and Prometheus scrapes
+ return request.getRequestURI().startsWith("/actuator");
+ }
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain) throws ServletException, IOException {
+ long start = System.currentTimeMillis();
+ try {
+ filterChain.doFilter(request, response);
+ } finally {
+ long duration = System.currentTimeMillis() - start;
+ String userEmail = resolveUserEmail();
+ log.info("method={} path={} status={} duration={}ms userEmail={}",
+ request.getMethod(),
+ request.getRequestURI(),
+ response.getStatus(),
+ duration,
+ userEmail);
+ }
+ }
+
+ private String resolveUserEmail() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
+ return auth.getName();
+ }
+ return "anonymous";
+ }
+}
diff --git a/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig
new file mode 100644
index 0000000..d270a3a
--- /dev/null
+++ b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig
@@ -0,0 +1,48 @@
+package com.jobtracker.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.lang.NonNull;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+public class RequestLoggingFilter extends OncePerRequestFilter {
+
+ private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain) throws ServletException, IOException {
+ long start = System.currentTimeMillis();
+ try {
+ filterChain.doFilter(request, response);
+ } finally {
+ long duration = System.currentTimeMillis() - start;
+ String userId = resolveUserId();
+ log.info("method={} path={} status={} duration={}ms userId={}",
+ request.getMethod(),
+ request.getRequestURI(),
+ response.getStatus(),
+ duration,
+ userId);
+ }
+ }
+
+ private String resolveUserId() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
+ return auth.getName();
+ }
+ return "anonymous";
+ }
+}
diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java
index f8d813d..77ea59b 100644
--- a/src/main/java/com/jobtracker/config/SecurityConfig.java
+++ b/src/main/java/com/jobtracker/config/SecurityConfig.java
@@ -18,10 +18,13 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CorsConfig corsConfig;
+ private final RequestLoggingFilter requestLoggingFilter;
- public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig) {
+ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig,
+ RequestLoggingFilter requestLoggingFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.corsConfig = corsConfig;
+ this.requestLoggingFilter = requestLoggingFilter;
}
@Bean
@@ -40,7 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.anyRequest().authenticated()
)
- .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .addFilterBefore(requestLoggingFilter, JwtAuthenticationFilter.class);
return http.build();
}
diff --git a/src/main/java/com/jobtracker/controller/ApplicationController.java b/src/main/java/com/jobtracker/controller/ApplicationController.java
index 1022ef0..dd8531c 100644
--- a/src/main/java/com/jobtracker/controller/ApplicationController.java
+++ b/src/main/java/com/jobtracker/controller/ApplicationController.java
@@ -2,6 +2,12 @@
import com.jobtracker.dto.application.*;
import com.jobtracker.service.ApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
@@ -12,6 +18,7 @@
import java.util.List;
import java.util.Map;
+@Tag(name = "Applications", description = "Job application management endpoints")
@RestController
@RequestMapping("/api/applications")
public class ApplicationController {
@@ -22,60 +29,140 @@ public ApplicationController(ApplicationService applicationService) {
this.applicationService = applicationService;
}
+ @Operation(
+ summary = "Create a job application",
+ description = "Creates a new job application for the authenticated user",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "Application created",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "400", description = "Validation error")
+ }
+ )
@PostMapping
public ResponseEntity create(@Valid @RequestBody ApplicationRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request));
}
+ @Operation(
+ summary = "Get application by ID",
+ description = "Returns a single job application owned by the authenticated user",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Application found",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@GetMapping("/{id}")
- public ResponseEntity getById(@PathVariable Long id) {
+ public ResponseEntity getById(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(applicationService.getById(id));
}
+ @Operation(
+ summary = "Update a job application",
+ description = "Replaces all fields of an existing job application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Application updated",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "400", description = "Validation error"),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@PutMapping("/{id}")
- public ResponseEntity update(@PathVariable Long id,
- @Valid @RequestBody ApplicationRequest request) {
+ public ResponseEntity update(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id,
+ @Valid @RequestBody ApplicationRequest request) {
return ResponseEntity.ok(applicationService.update(id, request));
}
+ @Operation(
+ summary = "Update application status",
+ description = "Partially updates only the status field of an application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Status updated",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@PatchMapping("/{id}/status")
- public ResponseEntity updateStatus(@PathVariable Long id,
- @Valid @RequestBody UpdateStatusRequest request) {
+ public ResponseEntity updateStatus(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id,
+ @Valid @RequestBody UpdateStatusRequest request) {
return ResponseEntity.ok(applicationService.updateStatus(id, request));
}
+ @Operation(
+ summary = "Update recruiter DM reminder",
+ description = "Enables or disables the recruiter DM reminder for an application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Reminder updated",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@PatchMapping("/{id}/reminder")
- public ResponseEntity updateReminder(@PathVariable Long id,
- @Valid @RequestBody UpdateReminderRequest request) {
+ public ResponseEntity updateReminder(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id,
+ @Valid @RequestBody UpdateReminderRequest request) {
return ResponseEntity.ok(applicationService.updateReminder(id, request));
}
+ @Operation(
+ summary = "Delete a job application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Application deleted"),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@DeleteMapping("/{id}")
- public ResponseEntity