16 KiB
16 KiB
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
# 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:
export const environment = {
production: false,
apiUrl: 'http://localhost:8000/api/v1'
};
Create src/environments/environment.prod.ts:
export const environment = {
production: true,
apiUrl: '/api/v1' // Proxy through same domain in production
};
Angular Material Theme
Update src/styles.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:
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:
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:
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:
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:
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
ng serve
# Access at http://localhost:4200
Production Build
ng build --configuration production
# Output in dist/frontend/
Docker Integration
Create frontend/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:
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
- Generate the Angular app:
ng new frontend - Install Material:
ng add @angular/material - Create the components shown above
- Test locally with
ng serve - Build and dockerize for production
- 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