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: `
+
+ `,
+ 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 @@
+
+
+
+
{{ 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 }}
+ 0">
+ {{ tag }}
+
+ |
+ {{ formatDate(artifact.created_at) }} |
+ {{ artifact.test_name || '-' }} |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
×
+
Artifact Details
+
+
+
ID
+
{{ selectedArtifact.id }}
+
+
+
Filename
+
{{ selectedArtifact.filename }}
+
+
+
File Type
+
{{ selectedArtifact.file_type }}
+
+
+
Size
+
{{ formatBytes(selectedArtifact.file_size) }}
+
+
+
Storage Path
+
{{ selectedArtifact.storage_path }}
+
+
+
Uploaded By
+
{{ selectedArtifact.test_name || '-' }}
+
+
+
Sim Source
+
{{ selectedArtifact.test_suite || '-' }}
+
+
+
SIM Source ID
+
{{ selectedArtifact.sim_source_id }}
+
+
+
Test Result
+
+
+ {{ selectedArtifact.test_result }}
+
+
+
+
+
Test Config
+
{{ selectedArtifact.test_config | json }}
+
+
+
Custom Metadata
+
{{ selectedArtifact.custom_metadata | json }}
+
+
+
Description
+
{{ selectedArtifact.description }}
+
+
0">
+
Tags
+
+ {{ tag }}
+
+
+
+
Version
+
{{ selectedArtifact.version || '-' }}
+
+
+
Created
+
{{ formatDate(selectedArtifact.created_at) }}
+
+
+
Updated
+
{{ formatDate(selectedArtifact.updated_at) }}
+
+
+
+
+
+
+
+
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 @@
+
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
+
+
+
+ {{ 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";
+ }
+}