diff --git a/.env.example b/.env.example index a132dd7..cf72db4 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,8 @@ MINIO_SECURE=false API_HOST=0.0.0.0 API_PORT=8000 MAX_UPLOAD_SIZE=524288000 + +# NPM Configuration (for frontend build) +# Leave blank or set to https://registry.npmjs.org/ for default npm registry +# Set to your custom npm proxy/registry URL if needed (e.g., http://your-nexus-server:8081/repository/npm-proxy/) +NPM_REGISTRY= diff --git a/Dockerfile.frontend.prebuilt b/Dockerfile.frontend.prebuilt new file mode 100644 index 0000000..4574cc1 --- /dev/null +++ b/Dockerfile.frontend.prebuilt @@ -0,0 +1,15 @@ +# Dockerfile for pre-built Angular frontend (air-gapped/restricted environments) +# Build the Angular app locally first: cd frontend && npm run build:prod +# Then use this Dockerfile to package the pre-built files + +FROM nginx:alpine + +# Copy pre-built Angular app to nginx +COPY frontend/dist/frontend/browser /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 99cc754..7d92151 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,465 +1,113 @@ -# Deployment Guide +# Deployment Options -This guide covers deploying the Test Artifact Data Lake in various environments. +This project supports two deployment strategies for the Angular frontend, depending on your environment's network access. -## Table of Contents -- [Local Development](#local-development) -- [Docker Compose](#docker-compose) -- [Kubernetes/Helm](#kuberneteshelm) -- [AWS Deployment](#aws-deployment) -- [Self-Hosted Deployment](#self-hosted-deployment) -- [GitLab CI/CD](#gitlab-cicd) +## Option 1: Standard Build (Internet Access Required) + +Use the standard `Dockerfile.frontend` which builds the Angular app inside Docker. + +**Requirements:** +- Internet access to npm registry +- Docker build environment + +**Usage:** +```bash +./quickstart.sh +# or +docker-compose up -d --build +``` + +This uses `Dockerfile.frontend` which: +1. Installs npm dependencies in Docker +2. Builds Angular app in Docker +3. Serves with nginx --- -## Local Development +## Option 2: Pre-built Deployment (Air-Gapped/Restricted Environments) -### Prerequisites -- Python 3.11+ -- PostgreSQL 15+ -- MinIO or AWS S3 access +Use `Dockerfile.frontend.prebuilt` for environments with restricted npm access or when esbuild platform binaries cannot be downloaded. -### Steps +**Requirements:** +- Node.js 24+ installed locally +- npm installed locally +- No internet required during Docker build -1. **Create virtual environment:** +**Usage:** + +### Step 1: Build Angular app locally ```bash -python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate +cd frontend +npm install # Only needed once or when dependencies change +npm run build:prod +cd .. ``` -2. **Install dependencies:** -```bash -pip install -r requirements.txt -``` +### Step 2: Update docker-compose.yml +Edit `docker-compose.yml` and change the frontend dockerfile: -3. **Set up PostgreSQL:** -```bash -createdb datalake -``` - -4. **Configure environment:** -```bash -cp .env.example .env -# Edit .env with your configuration -``` - -5. **Run the application:** -```bash -python -m uvicorn app.main:app --reload -``` - ---- - -## Docker Compose - -### Quick Start - -1. **Start all services:** -```bash -docker-compose up -d -``` - -2. **Check logs:** -```bash -docker-compose logs -f api -``` - -3. **Stop services:** -```bash -docker-compose down -``` - -### Services Included -- PostgreSQL (port 5432) -- MinIO (port 9000, console 9001) -- API (port 8000) - -### Customization - -Edit `docker-compose.yml` to: -- Change port mappings -- Adjust resource limits -- Add environment variables -- Configure volumes - ---- - -## Kubernetes/Helm - -### Prerequisites -- Kubernetes cluster (1.24+) -- Helm 3.x -- kubectl configured - -### Installation - -1. **Add dependencies (if using PostgreSQL/MinIO from Bitnami):** -```bash -helm repo add bitnami https://charts.bitnami.com/bitnami -helm repo update -``` - -2. **Install with default values:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace -``` - -3. **Custom installation:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set image.repository=your-registry/datalake \ - --set image.tag=1.0.0 \ - --set ingress.enabled=true \ - --set ingress.hosts[0].host=datalake.yourdomain.com -``` - -### Configuration Options - -**Image:** -```bash ---set image.repository=your-registry/datalake ---set image.tag=1.0.0 ---set image.pullPolicy=Always -``` - -**Resources:** -```bash ---set resources.requests.cpu=1000m ---set resources.requests.memory=1Gi ---set resources.limits.cpu=2000m ---set resources.limits.memory=2Gi -``` - -**Autoscaling:** -```bash ---set autoscaling.enabled=true ---set autoscaling.minReplicas=3 ---set autoscaling.maxReplicas=10 ---set autoscaling.targetCPUUtilizationPercentage=80 -``` - -**Ingress:** -```bash ---set ingress.enabled=true ---set ingress.className=nginx ---set ingress.hosts[0].host=datalake.example.com ---set ingress.hosts[0].paths[0].path=/ ---set ingress.hosts[0].paths[0].pathType=Prefix -``` - -### Upgrade - -```bash -helm upgrade datalake ./helm \ - --namespace datalake \ - --set image.tag=1.1.0 -``` - -### Uninstall - -```bash -helm uninstall datalake --namespace datalake -``` - ---- - -## AWS Deployment - -### Using AWS S3 Storage - -1. **Create S3 bucket:** -```bash -aws s3 mb s3://your-test-artifacts-bucket -``` - -2. **Create IAM user with S3 access:** -```bash -aws iam create-user --user-name datalake-service -aws iam attach-user-policy --user-name datalake-service \ - --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess -``` - -3. **Generate access keys:** -```bash -aws iam create-access-key --user-name datalake-service -``` - -4. **Deploy with Helm:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set config.storageBackend=s3 \ - --set aws.enabled=true \ - --set aws.accessKeyId=YOUR_ACCESS_KEY \ - --set aws.secretAccessKey=YOUR_SECRET_KEY \ - --set aws.region=us-east-1 \ - --set aws.bucketName=your-test-artifacts-bucket \ - --set minio.enabled=false -``` - -### Using EKS - -1. **Create EKS cluster:** -```bash -eksctl create cluster \ - --name datalake-cluster \ - --region us-east-1 \ - --nodegroup-name standard-workers \ - --node-type t3.medium \ - --nodes 3 -``` - -2. **Configure kubectl:** -```bash -aws eks update-kubeconfig --name datalake-cluster --region us-east-1 -``` - -3. **Deploy application:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set config.storageBackend=s3 -``` - -### Using RDS for PostgreSQL - -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set postgresql.enabled=false \ - --set config.databaseUrl="postgresql://user:pass@your-rds-endpoint:5432/datalake" -``` - ---- - -## Self-Hosted Deployment - -### Using MinIO - -1. **Deploy MinIO:** -```bash -helm install minio bitnami/minio \ - --namespace datalake \ - --create-namespace \ - --set auth.rootUser=admin \ - --set auth.rootPassword=adminpassword \ - --set persistence.size=100Gi -``` - -2. **Deploy application:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --set config.storageBackend=minio \ - --set minio.enabled=false \ - --set minio.endpoint=minio:9000 \ - --set minio.accessKey=admin \ - --set minio.secretKey=adminpassword -``` - -### On-Premise Kubernetes - -1. **Prepare persistent volumes:** ```yaml -apiVersion: v1 -kind: PersistentVolume -metadata: - name: datalake-postgres-pv -spec: - capacity: - storage: 20Gi - accessModes: - - ReadWriteOnce - hostPath: - path: /data/postgres + frontend: + build: + context: . + dockerfile: Dockerfile.frontend.prebuilt # <-- Change this line + ports: + - "4200:80" + depends_on: + - api ``` -2. **Deploy with local storage:** +### Step 3: Build and deploy ```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set postgresql.persistence.storageClass=local-storage \ - --set minio.persistence.storageClass=local-storage +docker-compose up -d --build ``` ---- - -## GitLab CI/CD - -### Setup - -1. **Configure GitLab variables:** - -Go to Settings → CI/CD → Variables and add: - -| Variable | Description | Protected | Masked | -|----------|-------------|-----------|---------| -| `CI_REGISTRY_USER` | Docker registry username | No | No | -| `CI_REGISTRY_PASSWORD` | Docker registry password | No | Yes | -| `KUBE_CONFIG_DEV` | Base64 kubeconfig for dev | No | Yes | -| `KUBE_CONFIG_STAGING` | Base64 kubeconfig for staging | Yes | Yes | -| `KUBE_CONFIG_PROD` | Base64 kubeconfig for prod | Yes | Yes | - -2. **Encode kubeconfig:** -```bash -cat ~/.kube/config | base64 -w 0 -``` - -### Pipeline Stages - -1. **Test**: Runs on all branches and MRs -2. **Build**: Builds Docker image on main/develop/tags -3. **Deploy**: Manual deployment to dev/staging/prod - -### Deployment Flow - -**Development:** -```bash -git push origin develop -# Manually trigger deploy:dev job in GitLab -``` - -**Staging:** -```bash -git push origin main -# Manually trigger deploy:staging job in GitLab -``` - -**Production:** -```bash -git tag v1.0.0 -git push origin v1.0.0 -# Manually trigger deploy:prod job in GitLab -``` - -### Customizing Pipeline - -Edit `.gitlab-ci.yml` to: -- Add more test stages -- Change deployment namespaces -- Adjust Helm values per environment -- Add security scanning -- Configure rollback procedures - ---- - -## Monitoring - -### Health Checks - -```bash -# Kubernetes -kubectl get pods -n datalake -kubectl logs -f -n datalake deployment/datalake - -# Direct -curl http://localhost:8000/health -``` - -### Metrics - -Add Prometheus monitoring: -```bash -helm install datalake ./helm \ - --set metrics.enabled=true \ - --set serviceMonitor.enabled=true -``` - ---- - -## Backup and Recovery - -### Database Backup - -```bash -# PostgreSQL -kubectl exec -n datalake deployment/datalake-postgresql -- \ - pg_dump -U user datalake > backup.sql - -# Restore -kubectl exec -i -n datalake deployment/datalake-postgresql -- \ - psql -U user datalake < backup.sql -``` - -### Storage Backup - -**S3:** -```bash -aws s3 sync s3://your-bucket s3://backup-bucket -``` - -**MinIO:** -```bash -mc mirror minio/test-artifacts backup/test-artifacts -``` +This uses `Dockerfile.frontend.prebuilt` which: +1. Copies pre-built Angular files from `frontend/dist/` +2. Serves with nginx +3. No npm/node required in Docker --- ## Troubleshooting -### Pod Not Starting -```bash -kubectl describe pod -n datalake -kubectl logs -n datalake +### esbuild Platform Binary Issues + +If you see errors like: +``` +Could not resolve "@esbuild/darwin-arm64" ``` -### Database Connection Issues -```bash -kubectl exec -it -n datalake deployment/datalake -- \ - psql $DATABASE_URL +**Solution 1:** Use Option 2 (Pre-built) above + +**Solution 2:** Add platform binaries to package.json (already included): +```json +"optionalDependencies": { + "@esbuild/darwin-arm64": "^0.25.4", + "@esbuild/darwin-x64": "^0.25.4", + "@esbuild/linux-arm64": "^0.25.4", + "@esbuild/linux-x64": "^0.25.4" +} ``` -### Storage Issues +**Solution 3:** Use custom npm registry with cached esbuild binaries + +### Custom NPM Registry + +For both options, you can use a custom npm registry: + ```bash -# Check MinIO -kubectl port-forward -n datalake svc/minio 9000:9000 -# Access http://localhost:9000 +# Set in .env file +NPM_REGISTRY=http://your-npm-proxy:8081/repository/npm-proxy/ + +# Or inline +NPM_REGISTRY=http://your-proxy ./quickstart.sh ``` --- -## Security Considerations +## Recommendation -1. **Use secrets management:** - - Kubernetes Secrets - - AWS Secrets Manager - - HashiCorp Vault - -2. **Enable TLS:** - - Configure ingress with TLS certificates - - Use cert-manager for automatic certificates - -3. **Network policies:** - - Restrict pod-to-pod communication - - Limit external access - -4. **RBAC:** - - Configure Kubernetes RBAC - - Limit service account permissions - ---- - -## Performance Tuning - -### Database -- Increase connection pool size -- Add database indexes -- Configure autovacuum - -### API -- Increase replica count -- Configure horizontal pod autoscaling -- Adjust resource requests/limits - -### Storage -- Use CDN for frequently accessed files -- Configure S3 Transfer Acceleration -- Optimize MinIO deployment +- **Development/Cloud**: Use Option 1 (standard) +- **Air-gapped/Enterprise**: Use Option 2 (pre-built) +- **CI/CD**: Use Option 2 for faster, more reliable builds diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html new file mode 100644 index 0000000..7528372 --- /dev/null +++ b/frontend/src/app/app.html @@ -0,0 +1,342 @@ + + + + + + + + + + + +
+
+
+ +

Hello, {{ title() }}

+

Congratulations! Your app is running. 🎉

+
+ +
+
+ @for (item of [ + { title: 'Explore the Docs', link: 'https://angular.dev' }, + { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, + { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, + { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, + { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, + { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, + ]; track item.title) { + + {{ item.title }} + + + + + } +
+ +
+
+
+ + + + + + + + + + + diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts new file mode 100644 index 0000000..d6439c4 --- /dev/null +++ b/frontend/src/app/app.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); + }); +}); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts new file mode 100644 index 0000000..eea6301 --- /dev/null +++ b/frontend/src/app/app.ts @@ -0,0 +1,53 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { ArtifactService } from './services/artifact'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive], + template: ` +
+
+

◆ Obsidian

+
+ {{ deploymentMode }} + {{ storageBackend }} +
+
+ + + + +
+ `, + styleUrls: ['./app.css'] +}) +export class AppComponent implements OnInit { + deploymentMode: string = ''; + storageBackend: string = ''; + + constructor(private artifactService: ArtifactService) {} + + ngOnInit() { + this.artifactService.getApiInfo().subscribe({ + next: (info) => { + this.deploymentMode = `Mode: ${info.deployment_mode}`; + this.storageBackend = `Storage: ${info.storage_backend}`; + }, + error: (err) => console.error('Failed to load API info:', err) + }); + } +} diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.css b/frontend/src/app/components/artifacts-list/artifacts-list.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.html b/frontend/src/app/components/artifacts-list/artifacts-list.html new file mode 100644 index 0000000..ad74e61 --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.html @@ -0,0 +1,190 @@ +
+
+ + + + + {{ filteredArtifacts.length }} artifacts + +
+ search + + +
+
+ +
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Sim Source + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + Artifacts + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + Date + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + Uploaded By + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + Actions
Loading artifacts...
No artifacts found. Upload some files to get started!
{{ artifact.sim_source_id || artifact.test_suite || '-' }} + {{ artifact.filename }} +
+ {{ tag }} +
+
{{ formatDate(artifact.created_at) }}{{ artifact.test_name || '-' }} +
+ + +
+
+
+ + +
+ + + diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.spec.ts b/frontend/src/app/components/artifacts-list/artifacts-list.spec.ts new file mode 100644 index 0000000..2a1cdbe --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ArtifactsList } from './artifacts-list'; + +describe('ArtifactsList', () => { + let component: ArtifactsList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtifactsList] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ArtifactsList); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.ts b/frontend/src/app/components/artifacts-list/artifacts-list.ts new file mode 100644 index 0000000..916dfdd --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.ts @@ -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(); + } +} diff --git a/frontend/src/app/components/query-form/query-form.css b/frontend/src/app/components/query-form/query-form.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/query-form/query-form.html b/frontend/src/app/components/query-form/query-form.html new file mode 100644 index 0000000..2529a96 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.html @@ -0,0 +1,102 @@ +
+

Query Artifacts

+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
diff --git a/frontend/src/app/components/query-form/query-form.spec.ts b/frontend/src/app/components/query-form/query-form.spec.ts new file mode 100644 index 0000000..b726afa --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QueryForm } from './query-form'; + +describe('QueryForm', () => { + let component: QueryForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [QueryForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(QueryForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/query-form/query-form.ts b/frontend/src/app/components/query-form/query-form.ts new file mode 100644 index 0000000..a209f38 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.ts @@ -0,0 +1,83 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; +import { ArtifactService } from '../../services/artifact'; +import { Artifact, ArtifactQuery } from '../../models/artifact.model'; + +@Component({ + selector: 'app-query-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './query-form.html', + styleUrls: ['./query-form.css'] +}) +export class QueryFormComponent { + queryForm: FormGroup; + @Output() resultsFound = new EventEmitter(); + + constructor( + private fb: FormBuilder, + private artifactService: ArtifactService + ) { + this.queryForm = this.fb.group({ + filename: [''], + file_type: [''], + test_name: [''], + test_suite: [''], + test_result: [''], + sim_source_id: [''], + tags: [''], + start_date: [''], + end_date: [''] + }); + } + + onSubmit() { + const query: ArtifactQuery = { + limit: 100, + offset: 0 + }; + + if (this.queryForm.value.filename) { + query.filename = this.queryForm.value.filename; + } + if (this.queryForm.value.file_type) { + query.file_type = this.queryForm.value.file_type; + } + if (this.queryForm.value.test_name) { + query.test_name = this.queryForm.value.test_name; + } + if (this.queryForm.value.test_suite) { + query.test_suite = this.queryForm.value.test_suite; + } + if (this.queryForm.value.test_result) { + query.test_result = this.queryForm.value.test_result; + } + if (this.queryForm.value.sim_source_id) { + query.sim_source_id = this.queryForm.value.sim_source_id; + } + if (this.queryForm.value.tags) { + query.tags = this.queryForm.value.tags + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t); + } + if (this.queryForm.value.start_date) { + query.start_date = new Date(this.queryForm.value.start_date).toISOString(); + } + if (this.queryForm.value.end_date) { + query.end_date = new Date(this.queryForm.value.end_date).toISOString(); + } + + this.artifactService.queryArtifacts(query).subscribe({ + next: (artifacts) => { + this.resultsFound.emit(artifacts); + }, + error: (err) => alert('Query failed: ' + err.message) + }); + } + + clearForm() { + this.queryForm.reset(); + } +} diff --git a/frontend/src/app/components/upload-form/upload-form.css b/frontend/src/app/components/upload-form/upload-form.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/upload-form/upload-form.html b/frontend/src/app/components/upload-form/upload-form.html new file mode 100644 index 0000000..e52ddc0 --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.html @@ -0,0 +1,109 @@ +
+

Upload Artifact

+
+
+ + + Supported: CSV, JSON, binary files, PCAP +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + Use same ID for multiple artifacts from same source +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ {{ uploadStatus.message }} +
+
diff --git a/frontend/src/app/components/upload-form/upload-form.spec.ts b/frontend/src/app/components/upload-form/upload-form.spec.ts new file mode 100644 index 0000000..b38c844 --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UploadForm } from './upload-form'; + +describe('UploadForm', () => { + let component: UploadForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UploadForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UploadForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/upload-form/upload-form.ts b/frontend/src/app/components/upload-form/upload-form.ts new file mode 100644 index 0000000..69caa34 --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.ts @@ -0,0 +1,132 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ArtifactService } from '../../services/artifact'; + +@Component({ + selector: 'app-upload-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './upload-form.html', + styleUrls: ['./upload-form.css'] +}) +export class UploadFormComponent { + uploadForm: FormGroup; + selectedFile: File | null = null; + uploading: boolean = false; + uploadStatus: { message: string, success: boolean } | null = null; + + constructor( + private fb: FormBuilder, + private artifactService: ArtifactService + ) { + this.uploadForm = this.fb.group({ + file: [null, Validators.required], + sim_source: ['', Validators.required], + uploaded_by: ['', Validators.required], + sim_source_id: [''], + tags: ['', Validators.required], + test_result: [''], + version: [''], + description: [''], + test_config: [''], + custom_metadata: [''] + }); + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.selectedFile = input.files[0]; + this.uploadForm.patchValue({ file: this.selectedFile }); + } + } + + onSubmit() { + if (!this.uploadForm.valid || !this.selectedFile) { + this.showStatus('Please fill in all required fields and select a file', false); + return; + } + + // Validate JSON fields + const testConfig = this.uploadForm.value.test_config; + const customMetadata = this.uploadForm.value.custom_metadata; + + if (testConfig) { + try { + JSON.parse(testConfig); + } catch (e) { + this.showStatus('Invalid Test Config JSON', false); + return; + } + } + + if (customMetadata) { + try { + JSON.parse(customMetadata); + } catch (e) { + this.showStatus('Invalid Custom Metadata JSON', false); + return; + } + } + + const formData = new FormData(); + formData.append('file', this.selectedFile); + formData.append('test_suite', this.uploadForm.value.sim_source); + formData.append('test_name', this.uploadForm.value.uploaded_by); + + if (this.uploadForm.value.sim_source_id) { + formData.append('sim_source_id', this.uploadForm.value.sim_source_id); + } + + // Parse and append tags as JSON array + if (this.uploadForm.value.tags) { + const tagsArray = this.uploadForm.value.tags + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t); + formData.append('tags', JSON.stringify(tagsArray)); + } + + if (this.uploadForm.value.test_result) { + formData.append('test_result', this.uploadForm.value.test_result); + } + + if (this.uploadForm.value.version) { + formData.append('version', this.uploadForm.value.version); + } + + if (this.uploadForm.value.description) { + formData.append('description', this.uploadForm.value.description); + } + + if (testConfig) { + formData.append('test_config', testConfig); + } + + if (customMetadata) { + formData.append('custom_metadata', customMetadata); + } + + this.uploading = true; + this.artifactService.uploadArtifact(formData).subscribe({ + next: (artifact) => { + this.showStatus(`Successfully uploaded: ${artifact.filename}`, true); + this.uploadForm.reset(); + this.selectedFile = null; + this.uploading = false; + }, + error: (err) => { + this.showStatus('Upload failed: ' + err.error?.detail || err.message, false); + this.uploading = false; + } + }); + } + + private showStatus(message: string, success: boolean) { + this.uploadStatus = { message, success }; + setTimeout(() => { + this.uploadStatus = null; + }, 5000); + } +} diff --git a/frontend/src/app/models/artifact.model.ts b/frontend/src/app/models/artifact.model.ts new file mode 100644 index 0000000..16d42cd --- /dev/null +++ b/frontend/src/app/models/artifact.model.ts @@ -0,0 +1,40 @@ +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; + test_result: string | null; + sim_source_id: string | null; + custom_metadata: any; + 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; + sim_source_id?: string; + tags?: string[]; + start_date?: string; + end_date?: string; + limit?: number; + offset?: number; +} + +export interface ApiInfo { + deployment_mode: string; + storage_backend: string; + version: string; +} diff --git a/frontend/src/app/services/artifact.ts b/frontend/src/app/services/artifact.ts new file mode 100644 index 0000000..6560f15 --- /dev/null +++ b/frontend/src/app/services/artifact.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Artifact, ArtifactQuery, ApiInfo } from '../models/artifact.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ArtifactService { + private apiUrl = '/api/v1/artifacts'; + private baseUrl = '/api'; + + constructor(private http: HttpClient) {} + + getApiInfo(): Observable { + return this.http.get(this.baseUrl); + } + + listArtifacts(limit: number = 100, offset: number = 0): Observable { + const params = new HttpParams() + .set('limit', limit.toString()) + .set('offset', offset.toString()); + return this.http.get(this.apiUrl + '/', { params }); + } + + getArtifact(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + queryArtifacts(query: ArtifactQuery): Observable { + return this.http.post(`${this.apiUrl}/query`, query); + } + + uploadArtifact(formData: FormData): Observable { + return this.http.post(`${this.apiUrl}/upload`, formData); + } + + downloadArtifact(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}/download`, { + responseType: 'blob' + }); + } + + deleteArtifact(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + generateSeedData(count: number): Observable { + return this.http.post(`/api/v1/seed/generate/${count}`, {}); + } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..0d04177 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,592 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #0f172a; + min-height: 100vh; + padding: 20px; + color: #e2e8f0; +} + +.container { + max-width: 1400px; + margin: 0 auto; + background: #1e293b; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +header { + background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%); + color: white; + padding: 30px; + display: flex; + justify-content: space-between; + align-items: center; +} + +header h1 { + font-size: 28px; + font-weight: 600; +} + +.header-info { + display: flex; + gap: 10px; +} + +.badge { + background: rgba(255, 255, 255, 0.2); + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + backdrop-filter: blur(10px); +} + +.tabs { + display: flex; + background: #0f172a; + border-bottom: 2px solid #334155; +} + +.tab-button { + flex: 1; + padding: 16px 24px; + background: none; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + color: #94a3b8; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.tab-button:hover { + background: #1e293b; + color: #e2e8f0; +} + +.tab-button.active { + background: #1e293b; + color: #60a5fa; + border-bottom: 3px solid #60a5fa; +} + +.tab-content { + display: none; + padding: 30px; +} + +.tab-content.active { + display: block; +} + +.toolbar { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: center; +} + +.filter-inline { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #0f172a; + border-radius: 6px; + border: 1px solid #334155; + min-width: 250px; +} + +.filter-inline input { + flex: 1; + padding: 4px 8px; + background: transparent; + border: none; + color: #e2e8f0; + font-size: 14px; +} + +.filter-inline input:focus { + outline: none; +} + +.filter-inline input::placeholder { + color: #64748b; +} + +.btn-clear { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; +} + +.btn-clear:hover { + background: #334155; + color: #e2e8f0; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn-primary { + background: #3b82f6; + color: white; +} + +.btn-primary:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.btn-secondary { + background: #334155; + color: #e2e8f0; +} + +.btn-secondary:hover { + background: #475569; +} + +.btn-danger { + background: #ef4444; + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-success { + background: #10b981; + color: white; +} + +.btn-large { + padding: 14px 28px; + font-size: 16px; +} + +.count-badge { + background: #1e3a8a; + color: #93c5fd; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + margin-left: auto; +} + +.table-container { + overflow-x: auto; + border: 1px solid #334155; + border-radius: 8px; + background: #0f172a; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +thead { + background: #1e293b; +} + +th { + padding: 14px 12px; + text-align: left; + font-weight: 600; + color: #94a3b8; + border-bottom: 2px solid #334155; + white-space: nowrap; + text-transform: uppercase; + font-size: 12px; + letter-spacing: 0.5px; +} + +th.sortable { + cursor: pointer; + user-select: none; + transition: color 0.3s; +} + +th.sortable:hover { + color: #60a5fa; +} + +.sort-indicator { + display: inline-block; + margin-left: 5px; + font-size: 10px; + color: #64748b; +} + +th.sort-asc .sort-indicator::after { + content: '▲'; + color: #60a5fa; +} + +th.sort-desc .sort-indicator::after { + content: '▼'; + color: #60a5fa; +} + +td { + padding: 16px 12px; + border-bottom: 1px solid #1e293b; + color: #cbd5e1; +} + +tbody tr:hover { + background: #1e293b; +} + +.loading { + text-align: center; + color: #64748b; + padding: 40px !important; +} + +.result-badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.result-pass { + background: #064e3b; + color: #6ee7b7; +} + +.result-fail { + background: #7f1d1d; + color: #fca5a5; +} + +.result-skip { + background: #78350f; + color: #fcd34d; +} + +.result-error { + background: #7f1d1d; + color: #fca5a5; +} + +.tag { + display: inline-block; + background: #1e3a8a; + color: #93c5fd; + padding: 3px 8px; + border-radius: 10px; + font-size: 11px; + margin: 2px; +} + +.file-type-badge { + background: #1e3a8a; + color: #93c5fd; + padding: 4px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-top: 20px; + padding: 20px; +} + +#page-info { + font-weight: 500; + color: #94a3b8; +} + +.upload-section, .query-section { + max-width: 800px; + margin: 0 auto; +} + +.form-group { + margin-bottom: 20px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +label { + display: block; + font-weight: 500; + color: #cbd5e1; + margin-bottom: 6px; + font-size: 14px; +} + +input[type="text"], +input[type="file"], +input[type="datetime-local"], +select, +textarea { + width: 100%; + padding: 10px 14px; + border: 1px solid #334155; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + transition: border-color 0.3s; + background: #0f172a; + color: #e2e8f0; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +small { + color: #64748b; + font-size: 12px; + display: block; + margin-top: 4px; +} + +#upload-status { + margin-top: 20px; + padding: 14px; + border-radius: 6px; + display: none; +} + +#upload-status.success { + background: #064e3b; + color: #6ee7b7; + display: block; +} + +#upload-status.error { + background: #7f1d1d; + color: #fca5a5; + display: block; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.modal.active { + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background: #1e293b; + padding: 30px; + border-radius: 12px; + max-width: 700px; + max-height: 80vh; + overflow-y: auto; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border: 1px solid #334155; +} + +.close { + position: absolute; + right: 20px; + top: 20px; + font-size: 28px; + font-weight: bold; + color: #64748b; + cursor: pointer; + transition: color 0.3s; +} + +.close:hover { + color: #e2e8f0; +} + +.detail-row { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid #334155; +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + font-weight: 600; + color: #94a3b8; + margin-bottom: 4px; +} + +.detail-value { + color: #cbd5e1; +} + +pre { + background: #0f172a; + padding: 12px; + border-radius: 6px; + overflow-x: auto; + font-size: 12px; + border: 1px solid #334155; +} + +code { + background: #0f172a; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + color: #93c5fd; +} + +.action-buttons { + display: flex; + gap: 8px; +} + +.icon-btn { + background: none; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.3s; + color: #94a3b8; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon-btn:hover { + background: #334155; + color: #e2e8f0; + transform: scale(1.1); +} + +/* Ensure SVG icons inherit color */ +.icon-btn svg { + stroke: currentColor; +} + +/* Artifact link styles - softer blue */ +.artifact-link { + color: #93c5fd; + text-decoration: none; + transition: color 0.3s; +} + +.artifact-link:hover { + color: #bfdbfe; + text-decoration: underline; +} + +/* Clickable row cursor */ +tr.clickable { + cursor: pointer; +} + +/* Search icon color */ +.search-icon { + color: #64748b; +} + +/* Material Icons */ +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 20px; + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + vertical-align: middle; +} + +.material-icons.md-16 { font-size: 16px; } +.material-icons.md-18 { font-size: 18px; } +.material-icons.md-20 { font-size: 20px; } +.material-icons.md-24 { font-size: 24px; } + +@media (max-width: 768px) { + .form-row { + grid-template-columns: 1fr; + } + + header { + flex-direction: column; + gap: 15px; + text-align: center; + } + + .table-container { + font-size: 12px; + } + + th, td { + padding: 8px 6px; + } + + .toolbar { + flex-wrap: wrap; + } +}