diff --git a/Dockerfile.app b/Dockerfile.app new file mode 100644 index 0000000..6790005 --- /dev/null +++ b/Dockerfile.app @@ -0,0 +1,32 @@ +# Multi-stage build combining frontend and nginx +FROM node:18-alpine AS frontend-build + +WORKDIR /app/frontend + +# Copy frontend files +COPY frontend/package*.json ./ +RUN npm install + +COPY frontend/ ./ +RUN npm run build + +# Final stage with nginx +FROM nginx:alpine + +# Remove default nginx config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built frontend from previous stage +COPY --from=frontend-build /app/frontend/dist/ldpv2-frontend/browser /usr/share/nginx/html + +# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index b9d449e..9f76025 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,5 +22,9 @@ COPY --from=build /app/target/*.jar app.jar # Expose port EXPOSE 8080 +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:8080/api/actuator/health || exit 1 + # Run the application -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index 2075d5f..442bf7a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -54,7 +54,10 @@ postgresql runtime - + + org.springframework.boot + spring-boot-starter-actuator + org.liquibase diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 73e15f0..c17f0d2 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -43,3 +43,12 @@ logging: level: com.ldpv2: DEBUG org.springframework.security: DEBUG + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: when-authorized \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bfb2f76..2d6e98d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,34 +33,31 @@ services: DB_USERNAME: ldpv2_user DB_PASSWORD: ldpv2_password JWT_SECRET: your-secret-key-change-in-production-minimum-512-bits-for-hs512-algorithm - ports: - - "8080:8080" networks: - ldpv2-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/actuator/health"] + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "/api/actuator/health"] interval: 30s timeout: 10s retries: 3 - start_period: 40s + start_period: 60s - frontend: + app: build: - context: ./frontend - dockerfile: Dockerfile - container_name: ldpv2-frontend + context: . + dockerfile: Dockerfile.app + container_name: ldpv2-app depends_on: - - backend + backend: + condition: service_healthy ports: - - "4200:80" + - "80:80" networks: - ldpv2-network - environment: - API_URL: http://backend:8080/api volumes: postgres_data: networks: ldpv2-network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/docker-compose.yml.old b/docker-compose.yml.old new file mode 100644 index 0000000..59bf504 --- /dev/null +++ b/docker-compose.yml.old @@ -0,0 +1,66 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: ldpv2-postgres + environment: + POSTGRES_DB: ldpv2 + POSTGRES_USER: ldpv2_user + POSTGRES_PASSWORD: ldpv2_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - ldpv2-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ldpv2_user -d ldpv2"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ldpv2-backend + depends_on: + postgres: + condition: service_healthy + environment: + DB_HOST: postgres + DB_USERNAME: ldpv2_user + DB_PASSWORD: ldpv2_password + JWT_SECRET: your-secret-key-change-in-production-minimum-512-bits-for-hs512-algorithm + ports: + - "8081:8080" + networks: + - ldpv2-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/api/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: ldpv2-frontend + depends_on: + - backend + ports: + - "4200:80" + networks: + - ldpv2-network + environment: + API_URL: http://backend:8081/api + +volumes: + postgres_data: + +networks: + ldpv2-network: + driver: bridge diff --git a/frontend/src/app/core/auth/auth.service.ts b/frontend/src/app/core/auth/auth.service.ts new file mode 100644 index 0000000..aeec280 --- /dev/null +++ b/frontend/src/app/core/auth/auth.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, BehaviorSubject, tap } from 'rxjs'; +import { LoginRequest, RegisterRequest, AuthResponse, User } from '../../shared/models/user.model'; +import { Router } from '@angular/router'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private readonly TOKEN_KEY = 'auth_token'; + private readonly USER_KEY = 'current_user'; + private currentUserSubject = new BehaviorSubject(this.getUserFromStorage()); + + public currentUser$ = this.currentUserSubject.asObservable(); + + constructor( + private http: HttpClient, + private router: Router + ) {} + + login(credentials: LoginRequest): Observable { + return this.http.post('/api/auth/login', credentials).pipe( + tap(response => { + this.setSession(response); + }) + ); + } + + register(data: RegisterRequest): Observable { + return this.http.post('/api/auth/register', data).pipe( + tap(response => { + this.setSession(response); + }) + ); + } + + logout(): void { + localStorage.removeItem(this.TOKEN_KEY); + localStorage.removeItem(this.USER_KEY); + this.currentUserSubject.next(null); + this.router.navigate(['/login']); + } + + isAuthenticated(): boolean { + return !!this.getToken(); + } + + getToken(): string | null { + return localStorage.getItem(this.TOKEN_KEY); + } + + getCurrentUser(): User | null { + return this.currentUserSubject.value; + } + + private setSession(authResponse: AuthResponse): void { + localStorage.setItem(this.TOKEN_KEY, authResponse.token); + localStorage.setItem(this.USER_KEY, JSON.stringify(authResponse.user)); + this.currentUserSubject.next(authResponse.user); + } + + private getUserFromStorage(): User | null { + const userJson = localStorage.getItem(this.USER_KEY); + return userJson ? JSON.parse(userJson) : null; + } +} diff --git a/frontend/src/app/core/auth/login/login.component.html b/frontend/src/app/core/auth/login/login.component.html new file mode 100644 index 0000000..f3e0677 --- /dev/null +++ b/frontend/src/app/core/auth/login/login.component.html @@ -0,0 +1,41 @@ + diff --git a/frontend/src/app/core/auth/login/login.component.scss b/frontend/src/app/core/auth/login/login.component.scss new file mode 100644 index 0000000..d2e36fe --- /dev/null +++ b/frontend/src/app/core/auth/login/login.component.scss @@ -0,0 +1,77 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: #f5f5f5; +} + +.login-card { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + width: 100%; + max-width: 400px; + + h1 { + margin-bottom: 1.5rem; + text-align: center; + color: #333; + } +} + +.form-group { + margin-bottom: 1rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + + &:focus { + outline: none; + border-color: #3f51b5; + } + + &.error { + border-color: #f44336; + } + } +} + +button { + width: 100%; + padding: 0.75rem; + background-color: #3f51b5; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + margin-top: 1rem; + + &:hover:not(:disabled) { + background-color: #303f9f; + } + + &:disabled { + background-color: #ccc; + cursor: not-allowed; + } +} + +.error-message { + color: #f44336; + font-size: 0.875rem; + margin-top: 0.25rem; +} diff --git a/frontend/src/app/core/auth/login/login.component.ts b/frontend/src/app/core/auth/login/login.component.ts new file mode 100644 index 0000000..9739f53 --- /dev/null +++ b/frontend/src/app/core/auth/login/login.component.ts @@ -0,0 +1,48 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { AuthService } from '../auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent { + loginForm: FormGroup; + loading = false; + error = ''; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router, + private route: ActivatedRoute + ) { + this.loginForm = this.fb.group({ + username: ['', [Validators.required]], + password: ['', [Validators.required]] + }); + } + + onSubmit(): void { + if (this.loginForm.valid) { + this.loading = true; + this.error = ''; + + this.authService.login(this.loginForm.value).subscribe({ + next: () => { + const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/environments'; + this.router.navigate([returnUrl]); + }, + error: (err) => { + this.error = err.error?.message || 'Login failed'; + this.loading = false; + } + }); + } + } +} diff --git a/frontend/src/app/core/guards/auth.guard.ts b/frontend/src/app/core/guards/auth.guard.ts new file mode 100644 index 0000000..510e31a --- /dev/null +++ b/frontend/src/app/core/guards/auth.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; +import { AuthService } from '../auth/auth.service'; + +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated()) { + return true; + } + + router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); + return false; +}; diff --git a/frontend/src/app/core/interceptors/error.interceptor.ts b/frontend/src/app/core/interceptors/error.interceptor.ts new file mode 100644 index 0000000..a64ada9 --- /dev/null +++ b/frontend/src/app/core/interceptors/error.interceptor.ts @@ -0,0 +1,19 @@ +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, throwError } from 'rxjs'; + +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + const router = inject(Router); + + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + localStorage.removeItem('auth_token'); + localStorage.removeItem('current_user'); + router.navigate(['/login']); + } + return throwError(() => error); + }) + ); +}; diff --git a/frontend/src/app/core/interceptors/jwt.interceptor.ts b/frontend/src/app/core/interceptors/jwt.interceptor.ts new file mode 100644 index 0000000..3319820 --- /dev/null +++ b/frontend/src/app/core/interceptors/jwt.interceptor.ts @@ -0,0 +1,18 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AuthService } from '../auth/auth.service'; + +export const jwtInterceptor: HttpInterceptorFn = (req, next) => { + const authService = inject(AuthService); + const token = authService.getToken(); + + if (token && !req.url.includes('/auth/')) { + req = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); + } + + return next(req); +}; diff --git a/frontend/src/app/features/environments/environment-detail/environment-detail.component.html b/frontend/src/app/features/environments/environment-detail/environment-detail.component.html new file mode 100644 index 0000000..2d913db --- /dev/null +++ b/frontend/src/app/features/environments/environment-detail/environment-detail.component.html @@ -0,0 +1,45 @@ +
+
Loading...
+
{{ error }}
+ +
+
+

{{ environment.name }}

+
+ + +
+
+ +
+
+ + {{ environment.description || '-' }} +
+ +
+ + + {{ environment.isProduction ? 'Yes' : 'No' }} + +
+ +
+ + {{ environment.criticalityLevel || '-' }} +
+ +
+ + {{ environment.createdAt | date:'medium' }} +
+ +
+ + {{ environment.updatedAt | date:'medium' }} +
+
+ + +
+
diff --git a/frontend/src/app/features/environments/environment-detail/environment-detail.component.scss b/frontend/src/app/features/environments/environment-detail/environment-detail.component.scss new file mode 100644 index 0000000..7dbc4e9 --- /dev/null +++ b/frontend/src/app/features/environments/environment-detail/environment-detail.component.scss @@ -0,0 +1,110 @@ +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.loading, .error { + text-align: center; + padding: 2rem; +} + +.error { + color: #f44336; +} + +.detail-card { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f5f5f5; + + h1 { + margin: 0; + } + + .actions { + display: flex; + gap: 0.5rem; + } +} + +.details { + margin-bottom: 2rem; +} + +.detail-row { + display: flex; + padding: 1rem 0; + border-bottom: 1px solid #f5f5f5; + + label { + font-weight: 600; + width: 200px; + color: #555; + } + + span { + flex: 1; + } +} + +.badge-prod { + background-color: #4caf50; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.badge-non-prod { + background-color: #9e9e9e; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.btn-primary, .btn-secondary, .btn-danger { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover { + background-color: #303f9f; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.btn-danger { + background-color: #f44336; + color: white; + + &:hover { + background-color: #d32f2f; + } +} diff --git a/frontend/src/app/features/environments/environment-detail/environment-detail.component.ts b/frontend/src/app/features/environments/environment-detail/environment-detail.component.ts new file mode 100644 index 0000000..36f4a4e --- /dev/null +++ b/frontend/src/app/features/environments/environment-detail/environment-detail.component.ts @@ -0,0 +1,68 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, ActivatedRoute } from '@angular/router'; +import { EnvironmentService } from '../environment.service'; +import { Environment } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-environment-detail', + standalone: true, + imports: [CommonModule], + templateUrl: './environment-detail.component.html', + styleUrls: ['./environment-detail.component.scss'] +}) +export class EnvironmentDetailComponent implements OnInit { + environment?: Environment; + loading = false; + error = ''; + + constructor( + private environmentService: EnvironmentService, + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.loadEnvironment(id); + } + } + + loadEnvironment(id: string): void { + this.loading = true; + this.environmentService.getEnvironment(id).subscribe({ + next: (env) => { + this.environment = env; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load environment'; + this.loading = false; + } + }); + } + + edit(): void { + if (this.environment) { + this.router.navigate(['/environments', this.environment.id, 'edit']); + } + } + + delete(): void { + if (this.environment && confirm('Are you sure you want to delete this environment?')) { + this.environmentService.deleteEnvironment(this.environment.id).subscribe({ + next: () => { + this.router.navigate(['/environments']); + }, + error: (err) => { + this.error = 'Failed to delete environment'; + } + }); + } + } + + back(): void { + this.router.navigate(['/environments']); + } +} diff --git a/frontend/src/app/features/environments/environment-form/environment-form.component.html b/frontend/src/app/features/environments/environment-form/environment-form.component.html new file mode 100644 index 0000000..5828db3 --- /dev/null +++ b/frontend/src/app/features/environments/environment-form/environment-form.component.html @@ -0,0 +1,60 @@ +
+

{{ isEditMode ? 'Edit Environment' : 'Create New Environment' }}

+ +
+
+ + +
+ Name is required (max 100 characters) +
+
+ +
+ + +
+ +
+ +
+ +
+ + +
+ Criticality level must be between 1 and 5 +
+
+ +
+ {{ error }} +
+ +
+ + +
+
+
diff --git a/frontend/src/app/features/environments/environment-form/environment-form.component.scss b/frontend/src/app/features/environments/environment-form/environment-form.component.scss new file mode 100644 index 0000000..d9c6fbb --- /dev/null +++ b/frontend/src/app/features/environments/environment-form/environment-form.component.scss @@ -0,0 +1,94 @@ +.container { + max-width: 600px; + margin: 0 auto; + padding: 2rem; + + h1 { + margin-bottom: 2rem; + } +} + +form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.form-group { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + input[type="text"], + input[type="number"], + textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + + &:focus { + outline: none; + border-color: #3f51b5; + } + + &.error { + border-color: #f44336; + } + } + + input[type="checkbox"] { + margin-right: 0.5rem; + } +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +.btn-primary, .btn-secondary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover:not(:disabled) { + background-color: #303f9f; + } + + &:disabled { + background-color: #ccc; + cursor: not-allowed; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.error-message { + color: #f44336; + font-size: 0.875rem; + margin-top: 0.25rem; +} diff --git a/frontend/src/app/features/environments/environment-form/environment-form.component.ts b/frontend/src/app/features/environments/environment-form/environment-form.component.ts new file mode 100644 index 0000000..b1935e5 --- /dev/null +++ b/frontend/src/app/features/environments/environment-form/environment-form.component.ts @@ -0,0 +1,82 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { EnvironmentService } from '../environment.service'; + +@Component({ + selector: 'app-environment-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './environment-form.component.html', + styleUrls: ['./environment-form.component.scss'] +}) +export class EnvironmentFormComponent implements OnInit { + form: FormGroup; + loading = false; + error = ''; + isEditMode = false; + environmentId?: string; + + constructor( + private fb: FormBuilder, + private environmentService: EnvironmentService, + private router: Router, + private route: ActivatedRoute + ) { + this.form = this.fb.group({ + name: ['', [Validators.required, Validators.maxLength(100)]], + description: [''], + isProduction: [false], + criticalityLevel: [null, [Validators.min(1), Validators.max(5)]] + }); + } + + ngOnInit(): void { + this.environmentId = this.route.snapshot.paramMap.get('id') || undefined; + this.isEditMode = !!this.environmentId; + + if (this.isEditMode && this.environmentId) { + this.loadEnvironment(this.environmentId); + } + } + + loadEnvironment(id: string): void { + this.loading = true; + this.environmentService.getEnvironment(id).subscribe({ + next: (env) => { + this.form.patchValue(env); + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load environment'; + this.loading = false; + } + }); + } + + onSubmit(): void { + if (this.form.valid) { + this.loading = true; + this.error = ''; + + const request$ = this.isEditMode && this.environmentId + ? this.environmentService.updateEnvironment(this.environmentId, this.form.value) + : this.environmentService.createEnvironment(this.form.value); + + request$.subscribe({ + next: () => { + this.router.navigate(['/environments']); + }, + error: (err) => { + this.error = err.error?.message || 'Failed to save environment'; + this.loading = false; + } + }); + } + } + + cancel(): void { + this.router.navigate(['/environments']); + } +} diff --git a/frontend/src/app/features/environments/environment-list/environment-list.component.html b/frontend/src/app/features/environments/environment-list/environment-list.component.html new file mode 100644 index 0000000..33f1e6f --- /dev/null +++ b/frontend/src/app/features/environments/environment-list/environment-list.component.html @@ -0,0 +1,50 @@ +
+
+

Environments

+ +
+ +
Loading...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + +
NameDescriptionProductionCriticalityActions
{{ env.name }}{{ env.description || '-' }} + + {{ env.isProduction ? 'Yes' : 'No' }} + + {{ env.criticalityLevel || '-' }} + + + +
+
+ +
+ No environments found. Click "Create New Environment" to get started. +
+ + +
diff --git a/frontend/src/app/features/environments/environment-list/environment-list.component.scss b/frontend/src/app/features/environments/environment-list/environment-list.component.scss new file mode 100644 index 0000000..8096a1d --- /dev/null +++ b/frontend/src/app/features/environments/environment-list/environment-list.component.scss @@ -0,0 +1,132 @@ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + + h1 { + margin: 0; + } +} + +.btn-primary { + background-color: #3f51b5; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: #303f9f; + } +} + +.loading, .error, .empty { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { + color: #f44336; +} + +.table-container { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + background: white; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + th, td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid #ddd; + } + + th { + background-color: #f5f5f5; + font-weight: 600; + } + + tbody tr:hover { + background-color: #f9f9f9; + } +} + +.badge-prod { + background-color: #4caf50; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.badge-non-prod { + background-color: #9e9e9e; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.btn-sm { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + background-color: #2196f3; + color: white; + + &:hover { + background-color: #1976d2; + } + + &.btn-danger { + background-color: #f44336; + + &:hover { + background-color: #d32f2f; + } + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; + + button { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #f5f5f5; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/frontend/src/app/features/environments/environment-list/environment-list.component.ts b/frontend/src/app/features/environments/environment-list/environment-list.component.ts new file mode 100644 index 0000000..57de019 --- /dev/null +++ b/frontend/src/app/features/environments/environment-list/environment-list.component.ts @@ -0,0 +1,87 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { EnvironmentService } from '../environment.service'; +import { Environment, Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-environment-list', + standalone: true, + imports: [CommonModule], + templateUrl: './environment-list.component.html', + styleUrls: ['./environment-list.component.scss'] +}) +export class EnvironmentListComponent implements OnInit { + environments: Environment[] = []; + loading = false; + error = ''; + + page = 0; + size = 20; + totalElements = 0; + totalPages = 0; + + constructor( + private environmentService: EnvironmentService, + private router: Router + ) {} + + ngOnInit(): void { + this.loadEnvironments(); + } + + loadEnvironments(): void { + this.loading = true; + this.environmentService.getEnvironments(this.page, this.size).subscribe({ + next: (data: Page) => { + this.environments = data.content; + this.totalElements = data.totalElements; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load environments'; + this.loading = false; + } + }); + } + + createNew(): void { + this.router.navigate(['/environments/new']); + } + + viewDetails(id: string): void { + this.router.navigate(['/environments', id]); + } + + edit(id: string): void { + this.router.navigate(['/environments', id, 'edit']); + } + + delete(id: string): void { + if (confirm('Are you sure you want to delete this environment?')) { + this.environmentService.deleteEnvironment(id).subscribe({ + next: () => { + this.loadEnvironments(); + }, + error: (err) => { + this.error = 'Failed to delete environment'; + } + }); + } + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + this.loadEnvironments(); + } + } + + previousPage(): void { + if (this.page > 0) { + this.page--; + this.loadEnvironments(); + } + } +} diff --git a/frontend/src/app/features/environments/environment.service.ts b/frontend/src/app/features/environments/environment.service.ts new file mode 100644 index 0000000..79d6ca3 --- /dev/null +++ b/frontend/src/app/features/environments/environment.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + Environment, + CreateEnvironmentRequest, + UpdateEnvironmentRequest, + Page +} from '../../shared/models/environment.model'; + +@Injectable({ + providedIn: 'root' +}) +export class EnvironmentService { + private readonly API_URL = '/api/environments'; + + constructor(private http: HttpClient) {} + + getEnvironments( + page: number = 0, + size: number = 20, + sortBy: string = 'name', + sortDirection: string = 'asc' + ): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()) + .set('sortBy', sortBy) + .set('sortDirection', sortDirection); + + return this.http.get>(this.API_URL, { params }); + } + + searchEnvironments(query: string, page: number = 0, size: number = 20): Observable> { + const params = new HttpParams() + .set('query', query) + .set('page', page.toString()) + .set('size', size.toString()); + + return this.http.get>(`${this.API_URL}/search`, { params }); + } + + getEnvironment(id: string): Observable { + return this.http.get(`${this.API_URL}/${id}`); + } + + createEnvironment(data: CreateEnvironmentRequest): Observable { + return this.http.post(this.API_URL, data); + } + + updateEnvironment(id: string, data: UpdateEnvironmentRequest): Observable { + return this.http.put(`${this.API_URL}/${id}`, data); + } + + deleteEnvironment(id: string): Observable { + return this.http.delete(`${this.API_URL}/${id}`); + } +} diff --git a/frontend/src/app/shared/models/environment.model.ts b/frontend/src/app/shared/models/environment.model.ts new file mode 100644 index 0000000..8079028 --- /dev/null +++ b/frontend/src/app/shared/models/environment.model.ts @@ -0,0 +1,43 @@ +export interface Environment { + id: string; + name: string; + description?: string; + isProduction: boolean; + criticalityLevel?: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateEnvironmentRequest { + name: string; + description?: string; + isProduction?: boolean; + criticalityLevel?: number; +} + +export interface UpdateEnvironmentRequest { + name?: string; + description?: string; + isProduction?: boolean; + criticalityLevel?: number; +} + +export interface Page { + content: T[]; + pageable: { + pageNumber: number; + pageSize: number; + sort: { + sorted: boolean; + unsorted: boolean; + }; + }; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; + size: number; + number: number; + numberOfElements: number; + empty: boolean; +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..60be2ad --- /dev/null +++ b/nginx.conf @@ -0,0 +1,74 @@ +server { + listen 80; + server_name localhost; + 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; + gzip_min_length 1000; + gzip_comp_level 6; + + # 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; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # API proxy to backend + location /api/ { + proxy_pass http://backend:8080/api/; + proxy_http_version 1.1; + + # Headers + proxy_set_header Host $host; + 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; + + # WebSocket support (if needed later) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_cache_bypass $http_upgrade; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # CORS headers (handled by backend, but adding here for safety) + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept' always; + + # Handle OPTIONS requests for CORS preflight + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept'; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } + } + + # Angular routes - fallback to index.html for SPA + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Don't log favicon requests + location = /favicon.ico { + log_not_found off; + access_log off; + } +} \ No newline at end of file