597 lines
16 KiB
Markdown
597 lines
16 KiB
Markdown
# Angular 19 Frontend Setup Guide
|
|
|
|
## Overview
|
|
|
|
This guide will help you set up the Angular 19 frontend with Material Design for the Test Artifact Data Lake.
|
|
|
|
## Prerequisites
|
|
|
|
- Node.js 18+ and npm
|
|
- Angular CLI 19
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
# Install Angular CLI globally
|
|
npm install -g @angular/cli@19
|
|
|
|
# Create new Angular 19 application
|
|
ng new frontend --routing --style=scss --standalone
|
|
|
|
# Navigate to frontend directory
|
|
cd frontend
|
|
|
|
# Install Angular Material
|
|
ng add @angular/material
|
|
|
|
# Install additional dependencies
|
|
npm install --save @angular/material @angular/cdk @angular/animations
|
|
npm install --save @ng-bootstrap/ng-bootstrap
|
|
|
|
# Start development server
|
|
ng serve
|
|
```
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
frontend/
|
|
├── src/
|
|
│ ├── app/
|
|
│ │ ├── components/
|
|
│ │ │ ├── artifact-list/
|
|
│ │ │ ├── artifact-upload/
|
|
│ │ │ ├── artifact-detail/
|
|
│ │ │ └── artifact-query/
|
|
│ │ ├── services/
|
|
│ │ │ └── artifact.service.ts
|
|
│ │ ├── models/
|
|
│ │ │ └── artifact.model.ts
|
|
│ │ ├── app.component.ts
|
|
│ │ └── app.routes.ts
|
|
│ ├── assets/
|
|
│ ├── environments/
|
|
│ │ ├── environment.ts
|
|
│ │ └── environment.prod.ts
|
|
│ └── styles.scss
|
|
├── angular.json
|
|
├── package.json
|
|
└── tsconfig.json
|
|
```
|
|
|
|
## Configuration Files
|
|
|
|
### Environment Configuration
|
|
|
|
Create `src/environments/environment.ts`:
|
|
|
|
```typescript
|
|
export const environment = {
|
|
production: false,
|
|
apiUrl: 'http://localhost:8000/api/v1'
|
|
};
|
|
```
|
|
|
|
Create `src/environments/environment.prod.ts`:
|
|
|
|
```typescript
|
|
export const environment = {
|
|
production: true,
|
|
apiUrl: '/api/v1' // Proxy through same domain in production
|
|
};
|
|
```
|
|
|
|
### Angular Material Theme
|
|
|
|
Update `src/styles.scss`:
|
|
|
|
```scss
|
|
@use '@angular/material' as mat;
|
|
|
|
@include mat.core();
|
|
|
|
$datalake-primary: mat.define-palette(mat.$indigo-palette);
|
|
$datalake-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
|
|
$datalake-warn: mat.define-palette(mat.$red-palette);
|
|
|
|
$datalake-theme: mat.define-light-theme((
|
|
color: (
|
|
primary: $datalake-primary,
|
|
accent: $datalake-accent,
|
|
warn: $datalake-warn,
|
|
),
|
|
typography: mat.define-typography-config(),
|
|
density: 0,
|
|
));
|
|
|
|
@include mat.all-component-themes($datalake-theme);
|
|
|
|
html, body {
|
|
height: 100%;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
font-family: Roboto, "Helvetica Neue", sans-serif;
|
|
}
|
|
```
|
|
|
|
## Core Files
|
|
|
|
### Models
|
|
|
|
Create `src/app/models/artifact.model.ts`:
|
|
|
|
```typescript
|
|
export interface Artifact {
|
|
id: number;
|
|
filename: string;
|
|
file_type: string;
|
|
file_size: number;
|
|
storage_path: string;
|
|
content_type: string | null;
|
|
test_name: string | null;
|
|
test_suite: string | null;
|
|
test_config: any | null;
|
|
test_result: string | null;
|
|
custom_metadata: any | null;
|
|
description: string | null;
|
|
tags: string[] | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
version: string | null;
|
|
parent_id: number | null;
|
|
}
|
|
|
|
export interface ArtifactQuery {
|
|
filename?: string;
|
|
file_type?: string;
|
|
test_name?: string;
|
|
test_suite?: string;
|
|
test_result?: string;
|
|
tags?: string[];
|
|
start_date?: string;
|
|
end_date?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface ApiInfo {
|
|
message: string;
|
|
version: string;
|
|
docs: string;
|
|
deployment_mode: string;
|
|
storage_backend: string;
|
|
}
|
|
```
|
|
|
|
### Service
|
|
|
|
Create `src/app/services/artifact.service.ts`:
|
|
|
|
```typescript
|
|
import { Injectable } from '@angular/core';
|
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|
import { Observable } from 'rxjs';
|
|
import { environment } from '../../environments/environment';
|
|
import { Artifact, ArtifactQuery, ApiInfo } from '../models/artifact.model';
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class ArtifactService {
|
|
private apiUrl = environment.apiUrl;
|
|
|
|
constructor(private http: HttpClient) {}
|
|
|
|
getApiInfo(): Observable<ApiInfo> {
|
|
return this.http.get<ApiInfo>(`${environment.apiUrl.replace('/api/v1', '')}/`);
|
|
}
|
|
|
|
listArtifacts(limit: number = 100, offset: number = 0): Observable<Artifact[]> {
|
|
return this.http.get<Artifact[]>(`${this.apiUrl}/artifacts/?limit=${limit}&offset=${offset}`);
|
|
}
|
|
|
|
getArtifact(id: number): Observable<Artifact> {
|
|
return this.http.get<Artifact>(`${this.apiUrl}/artifacts/${id}`);
|
|
}
|
|
|
|
queryArtifacts(query: ArtifactQuery): Observable<Artifact[]> {
|
|
return this.http.post<Artifact[]>(`${this.apiUrl}/artifacts/query`, query);
|
|
}
|
|
|
|
uploadArtifact(file: File, metadata: any): Observable<Artifact> {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
if (metadata.test_name) formData.append('test_name', metadata.test_name);
|
|
if (metadata.test_suite) formData.append('test_suite', metadata.test_suite);
|
|
if (metadata.test_result) formData.append('test_result', metadata.test_result);
|
|
if (metadata.test_config) formData.append('test_config', JSON.stringify(metadata.test_config));
|
|
if (metadata.custom_metadata) formData.append('custom_metadata', JSON.stringify(metadata.custom_metadata));
|
|
if (metadata.description) formData.append('description', metadata.description);
|
|
if (metadata.tags) formData.append('tags', JSON.stringify(metadata.tags));
|
|
if (metadata.version) formData.append('version', metadata.version);
|
|
|
|
return this.http.post<Artifact>(`${this.apiUrl}/artifacts/upload`, formData);
|
|
}
|
|
|
|
downloadArtifact(id: number): Observable<Blob> {
|
|
return this.http.get(`${this.apiUrl}/artifacts/${id}/download`, {
|
|
responseType: 'blob'
|
|
});
|
|
}
|
|
|
|
getDownloadUrl(id: number, expiration: number = 3600): Observable<{url: string, expires_in: number}> {
|
|
return this.http.get<{url: string, expires_in: number}>(
|
|
`${this.apiUrl}/artifacts/${id}/url?expiration=${expiration}`
|
|
);
|
|
}
|
|
|
|
deleteArtifact(id: number): Observable<{message: string}> {
|
|
return this.http.delete<{message: string}>(`${this.apiUrl}/artifacts/${id}`);
|
|
}
|
|
}
|
|
```
|
|
|
|
### App Routes
|
|
|
|
Create `src/app/app.routes.ts`:
|
|
|
|
```typescript
|
|
import { Routes } from '@angular/router';
|
|
import { ArtifactListComponent } from './components/artifact-list/artifact-list.component';
|
|
import { ArtifactUploadComponent } from './components/artifact-upload/artifact-upload.component';
|
|
import { ArtifactDetailComponent } from './components/artifact-detail/artifact-detail.component';
|
|
import { ArtifactQueryComponent } from './components/artifact-query/artifact-query.component';
|
|
|
|
export const routes: Routes = [
|
|
{ path: '', redirectTo: '/artifacts', pathMatch: 'full' },
|
|
{ path: 'artifacts', component: ArtifactListComponent },
|
|
{ path: 'upload', component: ArtifactUploadComponent },
|
|
{ path: 'query', component: ArtifactQueryComponent },
|
|
{ path: 'artifacts/:id', component: ArtifactDetailComponent },
|
|
];
|
|
```
|
|
|
|
### Main App Component
|
|
|
|
Create `src/app/app.component.ts`:
|
|
|
|
```typescript
|
|
import { Component, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { RouterOutlet, RouterLink } from '@angular/router';
|
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
|
import { MatListModule } from '@angular/material/list';
|
|
import { MatBadgeModule } from '@angular/material/badge';
|
|
import { ArtifactService } from './services/artifact.service';
|
|
import { ApiInfo } from './models/artifact.model';
|
|
|
|
@Component({
|
|
selector: 'app-root',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
RouterOutlet,
|
|
RouterLink,
|
|
MatToolbarModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatSidenavModule,
|
|
MatListModule,
|
|
MatBadgeModule
|
|
],
|
|
template: `
|
|
<mat-toolbar color="primary">
|
|
<button mat-icon-button (click)="sidenav.toggle()">
|
|
<mat-icon>menu</mat-icon>
|
|
</button>
|
|
<span>Test Artifact Data Lake</span>
|
|
<span class="spacer"></span>
|
|
<span *ngIf="apiInfo" class="mode-badge">
|
|
<mat-icon>{{ apiInfo.deployment_mode === 'cloud' ? 'cloud' : 'dns' }}</mat-icon>
|
|
{{ apiInfo.deployment_mode }}
|
|
</span>
|
|
</mat-toolbar>
|
|
|
|
<mat-sidenav-container>
|
|
<mat-sidenav #sidenav mode="side" opened>
|
|
<mat-nav-list>
|
|
<a mat-list-item routerLink="/artifacts" routerLinkActive="active">
|
|
<mat-icon matListItemIcon>list</mat-icon>
|
|
<span matListItemTitle>Artifacts</span>
|
|
</a>
|
|
<a mat-list-item routerLink="/upload" routerLinkActive="active">
|
|
<mat-icon matListItemIcon>cloud_upload</mat-icon>
|
|
<span matListItemTitle>Upload</span>
|
|
</a>
|
|
<a mat-list-item routerLink="/query" routerLinkActive="active">
|
|
<mat-icon matListItemIcon>search</mat-icon>
|
|
<span matListItemTitle>Query</span>
|
|
</a>
|
|
</mat-nav-list>
|
|
</mat-sidenav>
|
|
|
|
<mat-sidenav-content>
|
|
<div class="content-container">
|
|
<router-outlet></router-outlet>
|
|
</div>
|
|
</mat-sidenav-content>
|
|
</mat-sidenav-container>
|
|
`,
|
|
styles: [`
|
|
.spacer {
|
|
flex: 1 1 auto;
|
|
}
|
|
.mode-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 14px;
|
|
}
|
|
mat-sidenav-container {
|
|
height: calc(100vh - 64px);
|
|
}
|
|
mat-sidenav {
|
|
width: 250px;
|
|
}
|
|
.content-container {
|
|
padding: 20px;
|
|
}
|
|
.active {
|
|
background-color: rgba(0, 0, 0, 0.04);
|
|
}
|
|
`]
|
|
})
|
|
export class AppComponent implements OnInit {
|
|
title = 'Test Artifact Data Lake';
|
|
apiInfo: ApiInfo | null = null;
|
|
|
|
constructor(private artifactService: ArtifactService) {}
|
|
|
|
ngOnInit() {
|
|
this.artifactService.getApiInfo().subscribe(
|
|
info => this.apiInfo = info
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Component Examples
|
|
|
|
### Artifact List Component
|
|
|
|
Create `src/app/components/artifact-list/artifact-list.component.ts`:
|
|
|
|
```typescript
|
|
import { Component, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { RouterLink } from '@angular/router';
|
|
import { MatTableModule } from '@angular/material/table';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatChipsModule } from '@angular/material/chips';
|
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
|
import { ArtifactService } from '../../services/artifact.service';
|
|
import { Artifact } from '../../models/artifact.model';
|
|
|
|
@Component({
|
|
selector: 'app-artifact-list',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
RouterLink,
|
|
MatTableModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatChipsModule,
|
|
MatPaginatorModule
|
|
],
|
|
template: `
|
|
<h2>Artifacts</h2>
|
|
|
|
<table mat-table [dataSource]="artifacts" class="mat-elevation-z8">
|
|
<ng-container matColumnDef="id">
|
|
<th mat-header-cell *matHeaderCellDef>ID</th>
|
|
<td mat-cell *matCellDef="let artifact">{{ artifact.id }}</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="filename">
|
|
<th mat-header-cell *matHeaderCellDef>Filename</th>
|
|
<td mat-cell *matCellDef="let artifact">
|
|
<a [routerLink]="['/artifacts', artifact.id]">{{ artifact.filename }}</a>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="test_name">
|
|
<th mat-header-cell *matHeaderCellDef>Test Name</th>
|
|
<td mat-cell *matCellDef="let artifact">{{ artifact.test_name }}</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="test_result">
|
|
<th mat-header-cell *matHeaderCellDef>Result</th>
|
|
<td mat-cell *matCellDef="let artifact">
|
|
<mat-chip [color]="getResultColor(artifact.test_result)">
|
|
{{ artifact.test_result }}
|
|
</mat-chip>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="created_at">
|
|
<th mat-header-cell *matHeaderCellDef>Created</th>
|
|
<td mat-cell *matCellDef="let artifact">
|
|
{{ artifact.created_at | date:'short' }}
|
|
</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="actions">
|
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
|
<td mat-cell *matCellDef="let artifact">
|
|
<button mat-icon-button (click)="downloadArtifact(artifact.id)">
|
|
<mat-icon>download</mat-icon>
|
|
</button>
|
|
<button mat-icon-button color="warn" (click)="deleteArtifact(artifact.id)">
|
|
<mat-icon>delete</mat-icon>
|
|
</button>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
|
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
|
</table>
|
|
|
|
<mat-paginator
|
|
[length]="totalCount"
|
|
[pageSize]="pageSize"
|
|
[pageSizeOptions]="[10, 25, 50, 100]"
|
|
(page)="onPageChange($event)">
|
|
</mat-paginator>
|
|
`,
|
|
styles: [`
|
|
h2 {
|
|
margin-bottom: 20px;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
}
|
|
mat-paginator {
|
|
margin-top: 20px;
|
|
}
|
|
`]
|
|
})
|
|
export class ArtifactListComponent implements OnInit {
|
|
artifacts: Artifact[] = [];
|
|
displayedColumns = ['id', 'filename', 'test_name', 'test_result', 'created_at', 'actions'];
|
|
pageSize = 25;
|
|
totalCount = 1000; // You'd get this from a count endpoint
|
|
|
|
constructor(private artifactService: ArtifactService) {}
|
|
|
|
ngOnInit() {
|
|
this.loadArtifacts();
|
|
}
|
|
|
|
loadArtifacts(limit: number = 25, offset: number = 0) {
|
|
this.artifactService.listArtifacts(limit, offset).subscribe(
|
|
artifacts => this.artifacts = artifacts
|
|
);
|
|
}
|
|
|
|
onPageChange(event: PageEvent) {
|
|
this.loadArtifacts(event.pageSize, event.pageIndex * event.pageSize);
|
|
}
|
|
|
|
getResultColor(result: string | null): string {
|
|
switch (result) {
|
|
case 'pass': return 'primary';
|
|
case 'fail': return 'warn';
|
|
default: return 'accent';
|
|
}
|
|
}
|
|
|
|
downloadArtifact(id: number) {
|
|
this.artifactService.downloadArtifact(id).subscribe(blob => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `artifact_${id}`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
});
|
|
}
|
|
|
|
deleteArtifact(id: number) {
|
|
if (confirm('Are you sure you want to delete this artifact?')) {
|
|
this.artifactService.deleteArtifact(id).subscribe(
|
|
() => this.loadArtifacts()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Building and Deployment
|
|
|
|
### Development
|
|
```bash
|
|
ng serve
|
|
# Access at http://localhost:4200
|
|
```
|
|
|
|
### Production Build
|
|
```bash
|
|
ng build --configuration production
|
|
# Output in dist/frontend/
|
|
```
|
|
|
|
### Docker Integration
|
|
|
|
Create `frontend/Dockerfile`:
|
|
|
|
```dockerfile
|
|
# Build stage
|
|
FROM node:18-alpine AS builder
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
COPY . .
|
|
RUN npm run build -- --configuration production
|
|
|
|
# Production stage
|
|
FROM nginx:alpine
|
|
COPY --from=builder /app/dist/frontend/browser /usr/share/nginx/html
|
|
COPY nginx.conf /etc/nginx/nginx.conf
|
|
EXPOSE 80
|
|
CMD ["nginx", "-g", "daemon off;"]
|
|
```
|
|
|
|
Create `frontend/nginx.conf`:
|
|
|
|
```nginx
|
|
events {
|
|
worker_connections 1024;
|
|
}
|
|
|
|
http {
|
|
include /etc/nginx/mime.types;
|
|
default_type application/octet-stream;
|
|
|
|
server {
|
|
listen 80;
|
|
server_name localhost;
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
location /api/ {
|
|
proxy_pass http://api:8000;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection 'upgrade';
|
|
proxy_set_header Host $host;
|
|
proxy_cache_bypass $http_upgrade;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Next Steps
|
|
|
|
1. Generate the Angular app: `ng new frontend`
|
|
2. Install Material: `ng add @angular/material`
|
|
3. Create the components shown above
|
|
4. Test locally with `ng serve`
|
|
5. Build and dockerize for production
|
|
6. Update Helm chart to include frontend deployment
|
|
|
|
For complete component examples and advanced features, refer to:
|
|
- Angular Material documentation: https://material.angular.io
|
|
- Angular documentation: https://angular.dev
|