Merge pull request #56 from DerDavidBohl/experimental

Add Frontend
This commit is contained in:
David Bohl
2025-06-21 10:17:48 +02:00
committed by GitHub
65 changed files with 1594 additions and 1434 deletions

11
.gitignore vendored
View File

@@ -31,10 +31,13 @@ build/
### VS Code ###
.vscode/
/deployments/
/config/
backend/deployments/
backend/config/
### Config Files ###
/src/main/resources/application.properties
/src/main/resources/application-local.properties
backend/src/main/resources/application-local.properties
backend/data/
/backend/src/main/resources/static/
/data/
/deployments/
/config/

View File

@@ -1,8 +1,18 @@
# Use Maven image to build the application
FROM maven:3.9.9 AS build
FROM node:alpine AS frontend-build
WORKDIR /app
COPY pom.xml .
COPY src ./src
COPY frontend .
RUN rm -rf package-lock.json
RUN npm cache clean --force
RUN npm install
RUN npm run build
# Use Maven image to build the application
FROM maven:3.9.9 AS backend-build
WORKDIR /app
COPY backend/pom.xml .
COPY backend/src ./src
COPY --from=frontend-build /app/dist/browser ./src/main/resources/static
RUN mvn clean package -DskipTests
# Use OpenJDK image to run the application
@@ -20,6 +30,6 @@ RUN apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugi
# Finish
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
COPY --from=backend-build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=production"]

View File

@@ -2,7 +2,7 @@ POST http://localhost:8080/api/v1/deployments/test2/stop
###
POST http://localhost:8080/api/v1/deployments/all/start
POST http://localhost:8080/api/v1/deployments/all/start?force=true
###

View File

@@ -0,0 +1,23 @@
//package org.davidbohl.dirigent.ui;
//
//import jakarta.servlet.http.HttpServletRequest;
//import org.springframework.stereotype.Controller;
//import org.springframework.web.bind.annotation.RequestMapping;
//import org.springframework.web.servlet.HandlerMapping;
//import org.springframework.web.util.UrlPathHelper;
//
//@Controller
//public class SpaController {
//
// @RequestMapping(path = { "/ui/", "/ui/**"})
// public String forward(HttpServletRequest request) {
// UrlPathHelper pathHelper = new UrlPathHelper();
// String path = pathHelper.getPathWithinApplication(request);
//
// if (path.contains(".")) {
// return null;
// }
//
// return "forward:/ui/index.html";
// }
//}

View File

@@ -0,0 +1,16 @@
//package org.davidbohl.dirigent.ui;
//
//import org.springframework.context.annotation.Configuration;
//import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
//import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
//import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
//
//@Configuration
//public class WebConfig implements WebMvcConfigurer {
// @Override
// public void addViewControllers(ViewControllerRegistry registry) {
// // Forward only if it's NOT a request for a resource with a file extension (like .js, .css, .png)
// registry.addViewController("/ui/{path:[^\\.]*}/**") // Match "/ui/something" or "/ui/something/else", excluding URLs with dots.
// .setViewName("forward:/ui/index.html");
// }
//}

View File

@@ -8,8 +8,9 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=false
spring.jpa.hibernate.ddl-auto=update
dirigent.git.authToken=
dirigent.compose.command=docker compose
dirigent.start.all.on.startup=true
dirigent.git.authToken=
dirigent.delpoyments.schedule.enabled=true
dirigent.delpoyments.schedule.cron=0 */5 * * * *

View File

@@ -13,7 +13,7 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/dirigent-frontend",
"outputPath": "dist",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
@@ -94,5 +94,8 @@
}
}
}
},
"cli": {
"analytics": false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"

9
frontend/proxy.conf.json Normal file
View File

@@ -0,0 +1,9 @@
{
"/api/**": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"pathRewrite": {
}
}
}

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

View File

@@ -0,0 +1,15 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideHttpClient} from '@angular/common/http';
import {MAT_DIALOG_DEFAULT_OPTIONS} from '@angular/material/dialog';
export const appConfig: ApplicationConfig = {
providers: [
{provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: {hasBackdrop: true}},
provideHttpClient(),
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes)
]
};

View File

@@ -0,0 +1,9 @@
import {Routes} from '@angular/router';
import {OverviewComponent} from './overview/overview.component';
export const routes: Routes = [
{
path: '',
component: OverviewComponent
}
];

View File

@@ -0,0 +1,35 @@
import {Injectable} from '@angular/core';
import {Observable, ReplaySubject} from 'rxjs';
import {DeploymentState} from './deploymentState';
import {HttpClient} from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private _deploymentStates: ReplaySubject<Array<DeploymentState>> = new ReplaySubject<Array<DeploymentState>>(1);
constructor(private http: HttpClient) {
}
get deploymentStates$(): Observable<Array<DeploymentState>> {
return this._deploymentStates.asObservable();
}
updateDeploymentStates(): void {
this.getAllDeploymentStates().subscribe(r => this._deploymentStates.next(r));
}
getAllDeploymentStates(): Observable<Array<DeploymentState>> {
return this.http.get<Array<DeploymentState>>('api/v1/deployment-states');
}
stopDeployment(deploymentState: DeploymentState): Observable<void> {
return this.http.post<void>(`api/v1/deployments/${deploymentState.name}/stop`, {});
}
startDeployment(deploymentState: DeploymentState, force: boolean): Observable<void> {
return this.http.post<void>(`api/v1/deployments/${deploymentState.name}/start?force=${force}`, {});
}
}

View File

@@ -0,0 +1,5 @@
export interface DeploymentState {
name: string;
state: string;
message: string;
}

View File

@@ -0,0 +1,53 @@
<h1 class="text-3xl">Your Deployments</h1>
<div>
<table [dataSource]="dataSource" class="mat-elevation-z8" mat-table>
<!--- Note that these columns can be defined in any order.
The actual rendered columns are set as a property on the row definition" -->
<!-- Symbol Column -->
<ng-container matColumnDef="actions">
<th *matHeaderCellDef mat-header-cell> Actions</th>
<td *matCellDef="let element" mat-cell>
<button [matMenuTriggerFor]="menu" aria-label="Example icon-button with a menu" matIconButton>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button (click)="startDeployment(element)" mat-menu-item>
Start
</button>
<button (click)="stopDeployment(element)" mat-menu-item>
Stop
</button>
</mat-menu>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th *matHeaderCellDef mat-header-cell> Name</th>
<td *matCellDef="let element" mat-cell> {{ element.name }}</td>
</ng-container>
<!-- Weight Column -->
<ng-container matColumnDef="state">
<th *matHeaderCellDef mat-header-cell> State</th>
<td *matCellDef="let element" mat-cell>
<mat-chip class="bg-red-500">{{ element.state }}</mat-chip>
</td>
</ng-container>
<!-- Symbol Column -->
<ng-container matColumnDef="message">
<th *matHeaderCellDef mat-header-cell> Message</th>
<td *matCellDef="let element" mat-cell> {{ element.message }}</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns;" mat-row></tr>
</table>
</div>

View File

@@ -0,0 +1,81 @@
import {Component, inject} from '@angular/core';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable
} from '@angular/material/table';
import {DeploymentState} from './deploymentState';
import {ApiService} from './api.service';
import {MatMenu, MatMenuItem, MatMenuTrigger} from '@angular/material/menu';
import {MatIcon} from '@angular/material/icon';
import {MatChip} from '@angular/material/chips';
import {MatDialog} from '@angular/material/dialog';
import {StartDialogComponent} from './start-dialog/start-dialog.component';
import {interval} from 'rxjs';
@Component({
selector: 'app-overview',
imports: [
MatTable,
MatColumnDef,
MatHeaderCell,
MatCell,
MatHeaderCellDef,
MatCellDef,
MatHeaderRow,
MatRow,
MatRowDef,
MatHeaderRowDef,
MatMenuTrigger,
MatIcon,
MatMenu,
MatMenuItem,
MatChip,
],
templateUrl: './overview.component.html',
styleUrl: './overview.component.css',
})
export class OverviewComponent {
dataSource: Array<DeploymentState> = [];
displayedColumns = ['actions', 'name', 'state', 'message'];
readonly dialog = inject(MatDialog);
constructor(private apiService: ApiService) {
this.apiService.deploymentStates$.subscribe(states => {
if (JSON.stringify(states) !== JSON.stringify(this.dataSource)) {
this.dataSource = states;
}
});
this.apiService.updateDeploymentStates();
interval(2000).subscribe(() => this.apiService.updateDeploymentStates());
}
startDeployment(deploymentState: DeploymentState) {
const dialogRef = this.dialog.open(StartDialogComponent);
dialogRef.afterClosed().subscribe((result) => {
if (result.result) {
this.apiService.startDeployment(deploymentState, result.force)
.subscribe(() => this.apiService.updateDeploymentStates())
}
});
}
stopDeployment(element: DeploymentState) {
this.apiService.stopDeployment(element).subscribe(() => this.apiService.updateDeploymentStates());
}
}

View File

@@ -0,0 +1,13 @@
<h2 mat-dialog-title>Start Deployment</h2>
<mat-dialog-content>
<div>
Do you want to start the deployment?
</div>
</mat-dialog-content>
<mat-dialog-content class="h-20">
<mat-checkbox [(ngModel)]="force">Force</mat-checkbox>
</mat-dialog-content>
<mat-dialog-actions>
<button [mat-dialog-close]="{force, result: true}" mat-button>Yes</button>
<button (click)="dialogRef.close()" mat-button>No</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,32 @@
import {Component, inject} from '@angular/core';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from '@angular/material/dialog';
import {MatButton} from '@angular/material/button';
import {MatCheckbox} from '@angular/material/checkbox';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-start-dialog',
imports: [
MatDialogContent,
MatDialogActions,
MatDialogTitle,
MatButton,
MatCheckbox,
FormsModule,
MatDialogClose
],
templateUrl: './start-dialog.component.html',
styleUrl: './start-dialog.component.css'
})
export class StartDialogComponent {
readonly dialogRef = inject(MatDialogRef<StartDialogComponent>);
force: boolean = false;
}

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>DirigentFrontend</title>
<base href="/">
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="favicon.ico" rel="icon" type="image/x-icon">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

View File

@@ -1,29 +0,0 @@
import {TestBed} from '@angular/core/testing';
import {AppComponent} from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'dirigent-frontend' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('dirigent-frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, dirigent-frontend');
});
});

View File

@@ -1,8 +0,0 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes)]
};

View File

@@ -1,3 +0,0 @@
import {Routes} from '@angular/router';
export const routes: Routes = [];