autocomit

This commit is contained in:
2026-02-07 20:14:12 +01:00
parent bc5370d84d
commit 1b92b613d4
25 changed files with 1314 additions and 15 deletions
+32
View File
@@ -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;"]
+5 -1
View File
@@ -22,5 +22,9 @@ COPY --from=build /app/target/*.jar app.jar
# Expose port # Expose port
EXPOSE 8080 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 # Run the application
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]
+4 -1
View File
@@ -54,7 +54,10 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Liquibase --> <!-- Liquibase -->
<dependency> <dependency>
<groupId>org.liquibase</groupId> <groupId>org.liquibase</groupId>
@@ -43,3 +43,12 @@ logging:
level: level:
com.ldpv2: DEBUG com.ldpv2: DEBUG
org.springframework.security: DEBUG org.springframework.security: DEBUG
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: when-authorized
+10 -13
View File
@@ -33,34 +33,31 @@ services:
DB_USERNAME: ldpv2_user DB_USERNAME: ldpv2_user
DB_PASSWORD: ldpv2_password DB_PASSWORD: ldpv2_password
JWT_SECRET: your-secret-key-change-in-production-minimum-512-bits-for-hs512-algorithm JWT_SECRET: your-secret-key-change-in-production-minimum-512-bits-for-hs512-algorithm
ports:
- "8080:8080"
networks: networks:
- ldpv2-network - ldpv2-network
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/actuator/health"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "/api/actuator/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 60s
frontend: app:
build: build:
context: ./frontend context: .
dockerfile: Dockerfile dockerfile: Dockerfile.app
container_name: ldpv2-frontend container_name: ldpv2-app
depends_on: depends_on:
- backend backend:
condition: service_healthy
ports: ports:
- "4200:80" - "80:80"
networks: networks:
- ldpv2-network - ldpv2-network
environment:
API_URL: http://backend:8080/api
volumes: volumes:
postgres_data: postgres_data:
networks: networks:
ldpv2-network: ldpv2-network:
driver: bridge driver: bridge
+66
View File
@@ -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
@@ -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<User | null>(this.getUserFromStorage());
public currentUser$ = this.currentUserSubject.asObservable();
constructor(
private http: HttpClient,
private router: Router
) {}
login(credentials: LoginRequest): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
tap(response => {
this.setSession(response);
})
);
}
register(data: RegisterRequest): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/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;
}
}
@@ -0,0 +1,41 @@
<div class="login-container">
<div class="login-card">
<h1>LDPv2 Login</h1>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
type="text"
formControlName="username"
[class.error]="loginForm.get('username')?.invalid && loginForm.get('username')?.touched"
/>
<div class="error-message" *ngIf="loginForm.get('username')?.invalid && loginForm.get('username')?.touched">
Username is required
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
formControlName="password"
[class.error]="loginForm.get('password')?.invalid && loginForm.get('password')?.touched"
/>
<div class="error-message" *ngIf="loginForm.get('password')?.invalid && loginForm.get('password')?.touched">
Password is required
</div>
</div>
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<button type="submit" [disabled]="loginForm.invalid || loading">
{{ loading ? 'Loading...' : 'Login' }}
</button>
</form>
</div>
</div>
@@ -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;
}
@@ -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;
}
});
}
}
}
@@ -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;
};
@@ -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);
})
);
};
@@ -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);
};
@@ -0,0 +1,45 @@
<div class="container">
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="environment && !loading" class="detail-card">
<div class="header">
<h1>{{ environment.name }}</h1>
<div class="actions">
<button (click)="edit()" class="btn-primary">Edit</button>
<button (click)="delete()" class="btn-danger">Delete</button>
</div>
</div>
<div class="details">
<div class="detail-row">
<label>Description:</label>
<span>{{ environment.description || '-' }}</span>
</div>
<div class="detail-row">
<label>Production:</label>
<span [class.badge-prod]="environment.isProduction" [class.badge-non-prod]="!environment.isProduction">
{{ environment.isProduction ? 'Yes' : 'No' }}
</span>
</div>
<div class="detail-row">
<label>Criticality Level:</label>
<span>{{ environment.criticalityLevel || '-' }}</span>
</div>
<div class="detail-row">
<label>Created:</label>
<span>{{ environment.createdAt | date:'medium' }}</span>
</div>
<div class="detail-row">
<label>Updated:</label>
<span>{{ environment.updatedAt | date:'medium' }}</span>
</div>
</div>
<button (click)="back()" class="btn-secondary">Back to List</button>
</div>
</div>
@@ -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;
}
}
@@ -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']);
}
}
@@ -0,0 +1,60 @@
<div class="container">
<h1>{{ isEditMode ? 'Edit Environment' : 'Create New Environment' }}</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
type="text"
formControlName="name"
[class.error]="form.get('name')?.invalid && form.get('name')?.touched"
/>
<div class="error-message" *ngIf="form.get('name')?.invalid && form.get('name')?.touched">
Name is required (max 100 characters)
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
formControlName="description"
rows="4"
></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" formControlName="isProduction" />
Production Environment
</label>
</div>
<div class="form-group">
<label for="criticalityLevel">Criticality Level (1-5)</label>
<input
id="criticalityLevel"
type="number"
formControlName="criticalityLevel"
min="1"
max="5"
[class.error]="form.get('criticalityLevel')?.invalid && form.get('criticalityLevel')?.touched"
/>
<div class="error-message" *ngIf="form.get('criticalityLevel')?.invalid && form.get('criticalityLevel')?.touched">
Criticality level must be between 1 and 5
</div>
</div>
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<div class="form-actions">
<button type="button" (click)="cancel()" class="btn-secondary">Cancel</button>
<button type="submit" [disabled]="form.invalid || loading" class="btn-primary">
{{ loading ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
@@ -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;
}
@@ -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']);
}
}
@@ -0,0 +1,50 @@
<div class="container">
<div class="header">
<h1>Environments</h1>
<button (click)="createNew()" class="btn-primary">Create New Environment</button>
</div>
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && environments.length > 0" class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Production</th>
<th>Criticality</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let env of environments">
<td>{{ env.name }}</td>
<td>{{ env.description || '-' }}</td>
<td>
<span [class.badge-prod]="env.isProduction" [class.badge-non-prod]="!env.isProduction">
{{ env.isProduction ? 'Yes' : 'No' }}
</span>
</td>
<td>{{ env.criticalityLevel || '-' }}</td>
<td class="actions">
<button (click)="viewDetails(env.id)" class="btn-sm">View</button>
<button (click)="edit(env.id)" class="btn-sm">Edit</button>
<button (click)="delete(env.id)" class="btn-sm btn-danger">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="!loading && environments.length === 0" class="empty">
No environments found. Click "Create New Environment" to get started.
</div>
<div *ngIf="totalPages > 1" class="pagination">
<button (click)="previousPage()" [disabled]="page === 0">Previous</button>
<span>Page {{ page + 1 }} of {{ totalPages }}</span>
<button (click)="nextPage()" [disabled]="page >= totalPages - 1">Next</button>
</div>
</div>
@@ -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;
}
}
}
@@ -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<Environment>) => {
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();
}
}
}
@@ -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<Page<Environment>> {
const params = new HttpParams()
.set('page', page.toString())
.set('size', size.toString())
.set('sortBy', sortBy)
.set('sortDirection', sortDirection);
return this.http.get<Page<Environment>>(this.API_URL, { params });
}
searchEnvironments(query: string, page: number = 0, size: number = 20): Observable<Page<Environment>> {
const params = new HttpParams()
.set('query', query)
.set('page', page.toString())
.set('size', size.toString());
return this.http.get<Page<Environment>>(`${this.API_URL}/search`, { params });
}
getEnvironment(id: string): Observable<Environment> {
return this.http.get<Environment>(`${this.API_URL}/${id}`);
}
createEnvironment(data: CreateEnvironmentRequest): Observable<Environment> {
return this.http.post<Environment>(this.API_URL, data);
}
updateEnvironment(id: string, data: UpdateEnvironmentRequest): Observable<Environment> {
return this.http.put<Environment>(`${this.API_URL}/${id}`, data);
}
deleteEnvironment(id: string): Observable<void> {
return this.http.delete<void>(`${this.API_URL}/${id}`);
}
}
@@ -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<T> {
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;
}
+74
View File
@@ -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;
}
}