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>
236 lines
6.2 KiB
TypeScript
236 lines
6.2 KiB
TypeScript
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();
|
|
}
|
|
}
|