Migrate frontend to Angular 20 with full Docker support
Implemented a complete Angular 20 migration with modern standalone components architecture and production-ready Docker deployment: **Frontend Migration:** - Created Angular 20 application with standalone components (no NgModules) - Implemented three main components: artifacts-list, upload-form, query-form - Added TypeScript models and services for type-safe API communication - Migrated dark theme UI with all existing features - Configured routing and navigation between views - Set up development proxy for seamless API integration - Reactive forms with validation for upload and query functionality - Auto-refresh artifacts every 5 seconds with RxJS observables - Client-side sorting, filtering, and search capabilities - Tags displayed as inline badges, SIM source grouping support **Docker Integration:** - Multi-stage Dockerfile for Angular (Node 24 build, nginx Alpine serve) - nginx configuration for SPA routing and API proxy - Updated docker-compose.yml with frontend service on port 80 - Health checks for all services - Production-optimized build with gzip compression and asset caching **Technical Stack:** - Angular 20 with standalone components - TypeScript for type safety - RxJS for reactive programming - nginx as reverse proxy - Multi-stage Docker builds for optimal image size All features fully functional and tested in Docker environment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
235
frontend/src/app/components/artifacts-list/artifacts-list.ts
Normal file
235
frontend/src/app/components/artifacts-list/artifacts-list.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ArtifactService } from '../../services/artifact';
|
||||
import { Artifact } from '../../models/artifact.model';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-artifacts-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './artifacts-list.html',
|
||||
styleUrls: ['./artifacts-list.css']
|
||||
})
|
||||
export class ArtifactsListComponent implements OnInit, OnDestroy {
|
||||
artifacts: Artifact[] = [];
|
||||
filteredArtifacts: Artifact[] = [];
|
||||
selectedArtifact: Artifact | null = null;
|
||||
searchTerm: string = '';
|
||||
|
||||
// Pagination
|
||||
currentPage: number = 1;
|
||||
pageSize: number = 25;
|
||||
|
||||
// Auto-refresh
|
||||
autoRefreshEnabled: boolean = true;
|
||||
private refreshSubscription?: Subscription;
|
||||
private readonly REFRESH_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
// Sorting
|
||||
sortColumn: string | null = null;
|
||||
sortDirection: 'asc' | 'desc' = 'asc';
|
||||
|
||||
loading: boolean = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(private artifactService: ArtifactService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadArtifacts();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
loadArtifacts() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const offset = (this.currentPage - 1) * this.pageSize;
|
||||
this.artifactService.listArtifacts(this.pageSize, offset).subscribe({
|
||||
next: (artifacts) => {
|
||||
this.artifacts = artifacts;
|
||||
this.applyFilter();
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load artifacts: ' + err.message;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter() {
|
||||
if (!this.searchTerm) {
|
||||
this.filteredArtifacts = [...this.artifacts];
|
||||
} else {
|
||||
const term = this.searchTerm.toLowerCase();
|
||||
this.filteredArtifacts = this.artifacts.filter(artifact =>
|
||||
artifact.filename.toLowerCase().includes(term) ||
|
||||
(artifact.test_name && artifact.test_name.toLowerCase().includes(term)) ||
|
||||
(artifact.test_suite && artifact.test_suite.toLowerCase().includes(term)) ||
|
||||
(artifact.sim_source_id && artifact.sim_source_id.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
if (this.sortColumn) {
|
||||
this.applySorting();
|
||||
}
|
||||
}
|
||||
|
||||
onSearch() {
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchTerm = '';
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
sortTable(column: string) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.applySorting();
|
||||
}
|
||||
|
||||
applySorting() {
|
||||
if (!this.sortColumn) return;
|
||||
|
||||
this.filteredArtifacts.sort((a, b) => {
|
||||
const aVal = (a as any)[this.sortColumn!] || '';
|
||||
const bVal = (b as any)[this.sortColumn!] || '';
|
||||
|
||||
let comparison = 0;
|
||||
if (this.sortColumn === 'created_at') {
|
||||
comparison = new Date(aVal).getTime() - new Date(bVal).getTime();
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return this.sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.loadArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.currentPage++;
|
||||
this.loadArtifacts();
|
||||
}
|
||||
|
||||
toggleAutoRefresh() {
|
||||
this.autoRefreshEnabled = !this.autoRefreshEnabled;
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh();
|
||||
} else {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private startAutoRefresh() {
|
||||
if (!this.autoRefreshEnabled) return;
|
||||
|
||||
this.refreshSubscription = interval(this.REFRESH_INTERVAL)
|
||||
.pipe(switchMap(() => this.artifactService.listArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize)))
|
||||
.subscribe({
|
||||
next: (artifacts) => {
|
||||
this.artifacts = artifacts;
|
||||
this.applyFilter();
|
||||
},
|
||||
error: (err) => console.error('Auto-refresh error:', err)
|
||||
});
|
||||
}
|
||||
|
||||
private stopAutoRefresh() {
|
||||
if (this.refreshSubscription) {
|
||||
this.refreshSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
showDetail(artifact: Artifact) {
|
||||
this.selectedArtifact = artifact;
|
||||
}
|
||||
|
||||
closeDetail() {
|
||||
this.selectedArtifact = null;
|
||||
}
|
||||
|
||||
downloadArtifact(artifact: Artifact, event: Event) {
|
||||
event.stopPropagation();
|
||||
this.artifactService.downloadArtifact(artifact.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = artifact.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
},
|
||||
error: (err) => alert('Download failed: ' + err.message)
|
||||
});
|
||||
}
|
||||
|
||||
deleteArtifact(artifact: Artifact, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (!confirm(`Are you sure you want to delete ${artifact.filename}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifactService.deleteArtifact(artifact.id).subscribe({
|
||||
next: () => {
|
||||
this.loadArtifacts();
|
||||
if (this.selectedArtifact?.id === artifact.id) {
|
||||
this.closeDetail();
|
||||
}
|
||||
},
|
||||
error: (err) => alert('Delete failed: ' + err.message)
|
||||
});
|
||||
}
|
||||
|
||||
generateSeedData() {
|
||||
const count = prompt('How many artifacts to generate? (1-100)', '10');
|
||||
if (!count) return;
|
||||
|
||||
const num = parseInt(count);
|
||||
if (isNaN(num) || num < 1 || num > 100) {
|
||||
alert('Please enter a number between 1 and 100');
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifactService.generateSeedData(num).subscribe({
|
||||
next: (result) => {
|
||||
alert(result.message);
|
||||
this.loadArtifacts();
|
||||
},
|
||||
error: (err) => alert('Generation failed: ' + err.message)
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user