Files
warehouse13/docs/FRONTEND_SETUP.md

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

  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: