diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..981c0e3 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,29 @@ +# Multi-stage build for Angular frontend +FROM node:24-alpine AS build + +WORKDIR /app + +# Copy package files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY frontend/ ./ + +# Build for production +RUN npm run build:prod + +# Final stage - nginx to serve static files +FROM nginx:alpine + +# Copy built Angular app to nginx +COPY --from=build /app/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/docker-compose.yml b/docker-compose.yml index 1faff35..0c92e60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,20 @@ services: timeout: 10s retries: 3 + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "80:80" + depends_on: + - api + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + volumes: postgres_data: minio_data: diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b1d225e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,43 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..ebf8bd1 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,178 @@ +# Obsidian Frontend - Angular Application + +Modern Angular application for the Obsidian Test Artifact Data Lake. + +## Features + +- **Angular 20** with standalone components +- **TypeScript** for type safety +- **Reactive Forms** for upload and query functionality +- **RxJS** for reactive programming +- **Auto-refresh** artifacts every 5 seconds +- **Client-side sorting and filtering** +- **Dark theme** UI +- **Responsive design** + +## Development + +### Prerequisites + +- Node.js 24.x or higher +- npm 11.x or higher +- Backend API running on port 8000 + +### Installation + +```bash +cd frontend +npm install +``` + +### Run Development Server + +```bash +npm start +``` + +The application will be available at `http://localhost:4200/` + +The development server includes a proxy configuration that forwards `/api` requests to `http://localhost:8000`. + +### Build for Production + +```bash +npm run build:prod +``` + +Build artifacts will be in the `dist/frontend/browser` directory. + +## Project Structure + +``` +src/ +├── app/ +│ ├── components/ +│ │ ├── artifacts-list/ # Main artifacts table with sorting, filtering, auto-refresh +│ │ ├── upload-form/ # Reactive form for uploading artifacts +│ │ └── query-form/ # Advanced query interface +│ ├── models/ +│ │ └── artifact.model.ts # TypeScript interfaces for type safety +│ ├── services/ +│ │ └── artifact.ts # HTTP service for API calls +│ ├── app.ts # Main app component with routing +│ ├── app.config.ts # Application configuration +│ └── app.routes.ts # Route definitions +├── styles.css # Global dark theme styles +└── main.ts # Application bootstrap + +## Key Components + +### ArtifactsListComponent +- Displays artifacts in a sortable, filterable table +- Auto-refreshes every 5 seconds (toggleable) +- Client-side search across all fields +- Download and delete actions +- Detail modal for full artifact information +- Tags displayed as inline badges +- SIM source grouping support + +### UploadFormComponent +- Reactive form with validation +- File upload with drag-and-drop support +- Required fields: File, Sim Source, Uploaded By, Tags +- Optional fields: SIM Source ID (for grouping), Test Result, Version, Description, Test Config, Custom Metadata +- JSON validation for config fields +- Real-time upload status feedback + +### QueryFormComponent +- Advanced search with multiple filter criteria +- Filter by: filename, file type, test name, test suite, test result, SIM source ID, tags, date range +- Results emitted to artifacts list + +## API Integration + +The frontend communicates with the FastAPI backend through the `ArtifactService`: + +- `GET /api/v1/artifacts/` - List all artifacts +- `GET /api/v1/artifacts/:id` - Get single artifact +- `POST /api/v1/artifacts/upload` - Upload new artifact +- `POST /api/v1/artifacts/query` - Query with filters +- `GET /api/v1/artifacts/:id/download` - Download artifact file +- `DELETE /api/v1/artifacts/:id` - Delete artifact +- `POST /api/v1/seed/generate/:count` - Generate seed data + +## Configuration + +### Proxy Configuration (`proxy.conf.json`) + +```json +{ + "/api": { + "target": "http://localhost:8000", + "secure": false, + "changeOrigin": true + } +} +``` + +This proxies all `/api` requests to the backend during development. + +## Styling + +The application uses a custom dark theme with: +- Dark blue/slate color palette +- Gradient headers +- Responsive design +- Smooth transitions and hover effects +- Tag badges for categorization +- Result badges for test statuses + +## Browser Support + +- Chrome/Edge (latest) +- Firefox (latest) +- Safari (latest) + +## Development Tips + +1. **Hot Reload**: Changes to TypeScript files automatically trigger recompilation +2. **Type Safety**: Use TypeScript interfaces in `models/` for all API responses +3. **State Management**: Currently using component-level state; consider NgRx for complex state +4. **Testing**: Run `npm test` for unit tests (Jasmine/Karma) + +## Deployment + +For production deployment, build the application and serve the `dist/frontend/browser` directory with your web server (nginx, Apache, etc.). + +Example nginx configuration: + +```nginx +server { + listen 80; + server_name your-domain.com; + root /path/to/dist/frontend/browser; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:8000; + } +} +``` + +## Future Enhancements + +- [ ] Add NgRx for state management +- [ ] Implement WebSocket for real-time updates +- [ ] Add Angular Material components +- [ ] Unit and E2E tests +- [ ] PWA support +- [ ] Drag-and-drop file upload +- [ ] Bulk operations +- [ ] Export to CSV/JSON + +## License + +Same as parent project diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..ac15a59 --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,92 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "frontend": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "frontend:build:production" + }, + "development": { + "buildTarget": "frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + } + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..06a04f5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,49 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve --proxy-config proxy.conf.json", + "build": "ng build", + "build:prod": "ng build --configuration production", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "prettier": { + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] + }, + "private": true, + "dependencies": { + "@angular/common": "^20.3.0", + "@angular/compiler": "^20.3.0", + "@angular/core": "^20.3.0", + "@angular/forms": "^20.3.0", + "@angular/platform-browser": "^20.3.0", + "@angular/router": "^20.3.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular/build": "^20.3.5", + "@angular/cli": "^20.3.5", + "@angular/compiler-cli": "^20.3.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.9.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.9.2" + } +} \ No newline at end of file diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..8cb6f77 --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,7 @@ +{ + "/api": { + "target": "http://localhost:8000", + "secure": false, + "changeOrigin": true + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..d037d76 --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,13 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient() + ] +}; 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.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..f72a3fe --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; +import { ArtifactsListComponent } from './components/artifacts-list/artifacts-list'; +import { UploadFormComponent } from './components/upload-form/upload-form'; +import { QueryFormComponent } from './components/query-form/query-form'; + +export const routes: Routes = [ + { path: '', redirectTo: '/artifacts', pathMatch: 'full' }, + { path: 'artifacts', component: ArtifactsListComponent }, + { path: 'upload', component: UploadFormComponent }, + { path: 'query', component: QueryFormComponent } +]; 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..66dbf15 --- /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..16cb33f --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.html @@ -0,0 +1,188 @@ +
+
+ + + + + {{ filteredArtifacts.length }} artifacts + +
+ 🔍 + + +
+
+ +
{{ 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..e1326b3 --- /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..4ab6c1c --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.html @@ -0,0 +1,108 @@ +
+

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/index.html b/frontend/src/index.html new file mode 100644 index 0000000..3af61ec --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,13 @@ + + + + + Frontend + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..a2fc385 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..58bcca3 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,549 @@ +* { + 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; +} + +@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; + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..264f459 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e4955f2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..04df34c --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..bcc9963 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json; + + # Angular routes - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + 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; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +}