Switch to angular
This commit is contained in:
17
frontend/.editorconfig
Normal file
17
frontend/.editorconfig
Normal file
@@ -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
|
||||
42
frontend/.gitignore
vendored
Normal file
42
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
151
frontend/README.md
Normal file
151
frontend/README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Test Artifact Data Lake - Angular Frontend
|
||||
|
||||
This is the Angular 19 frontend for the Test Artifact Data Lake application. It replaces the static HTML/JS implementation with a modern, component-based architecture.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Multi-component Architecture**: Built with reusable Angular components
|
||||
✅ **Tab Navigation**: Clean tab-based interface for Artifacts, Upload, and Query
|
||||
✅ **Event ID Support**: Group multiple artifacts under the same event ID
|
||||
✅ **Expandable Binaries Display**: Show first 4 binaries, expandable for more
|
||||
✅ **Advanced Tag Management**: Create tags on-the-spot with database persistence
|
||||
✅ **Scoped Tags**: Organize tags by scope (project, environment, priority, etc.)
|
||||
✅ **Comprehensive Filtering**: Filter artifacts by all table criteria
|
||||
✅ **Real-time Search**: As-you-type filtering in query form
|
||||
✅ **Responsive Design**: Mobile-friendly interface
|
||||
|
||||
## Components
|
||||
|
||||
### Core Components
|
||||
- **TabNavigationComponent**: Manages tab switching between Artifacts, Upload, and Query
|
||||
- **ArtifactsTableComponent**: Displays artifacts with expandable binaries/tags and Event ID support
|
||||
- **UploadFormComponent**: File upload with Event ID and binaries support
|
||||
- **QueryFormComponent**: Advanced search with real-time filtering
|
||||
- **TagManagerComponent**: On-the-spot tag creation with scoped tags
|
||||
|
||||
### Services
|
||||
- **ArtifactService**: Handles all artifact-related API calls
|
||||
- **ApiService**: Manages general API information
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18 or later)
|
||||
- Angular CLI 19
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
npm start
|
||||
# or
|
||||
ng serve
|
||||
```
|
||||
The app will be available at `http://localhost:4200`
|
||||
|
||||
### Build for Production
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
ng build
|
||||
```
|
||||
Built files will be in `dist/frontend/`
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend expects the backend API to be available at:
|
||||
- Development: Same origin as the frontend
|
||||
- Production: Configurable via environment files
|
||||
|
||||
### Required API Endpoints
|
||||
- `GET /api` - API information
|
||||
- `GET /api/v1/artifacts/` - List artifacts
|
||||
- `GET /api/v1/artifacts/{id}` - Get artifact details
|
||||
- `POST /api/v1/artifacts/upload` - Upload artifact
|
||||
- `DELETE /api/v1/artifacts/{id}` - Delete artifact
|
||||
- `GET /api/v1/artifacts/{id}/download` - Download artifact
|
||||
- `POST /api/v1/artifacts/query` - Query artifacts
|
||||
- `POST /api/v1/seed/generate/{count}` - Generate seed data
|
||||
- `GET /api/v1/tags` - List all tags
|
||||
- `POST /api/v1/tags` - Create tag
|
||||
- `POST /api/v1/artifacts/{id}/tags` - Add tag to artifact
|
||||
- `DELETE /api/v1/artifacts/{id}/tags/{tag_id}` - Remove tag from artifact
|
||||
|
||||
## Key Features Implementation
|
||||
|
||||
### Event ID Support
|
||||
Each artifact can be assigned an Event ID to group related artifacts together. This is displayed prominently in the table and can be used for filtering.
|
||||
|
||||
### Expandable Binaries
|
||||
When an artifact has more than 4 associated binaries, only the first 4 are shown with a "+X more" button to expand and see all binaries.
|
||||
|
||||
### Advanced Tag Management
|
||||
- Create tags on-the-spot directly in the table
|
||||
- Organize tags by scope (project, environment, priority, category, status)
|
||||
- Tags persist in the database across app restarts
|
||||
- Visual indicators show which tags are already attached
|
||||
- Quick-add existing tags from a categorized list
|
||||
|
||||
### Comprehensive Filtering
|
||||
The query form provides real-time filtering by:
|
||||
- Filename (partial match)
|
||||
- File type
|
||||
- Test name
|
||||
- Test suite
|
||||
- Test result
|
||||
- Tags (comma-separated)
|
||||
- Date range
|
||||
|
||||
Filters are applied immediately as you type, and active filters are displayed as visual chips.
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### From Static to Angular
|
||||
The original static JavaScript implementation has been converted to:
|
||||
|
||||
1. **Component-based Architecture**: Each major feature is now a reusable component
|
||||
2. **Type Safety**: Full TypeScript support with proper interfaces
|
||||
3. **Reactive Programming**: Uses RxJS observables for API calls
|
||||
4. **State Management**: Centralized state management through services
|
||||
5. **Modular Design**: Easy to extend and maintain
|
||||
|
||||
### Benefits
|
||||
- **Maintainability**: Clear separation of concerns
|
||||
- **Reusability**: Components can be reused and extended
|
||||
- **Testing**: Angular's testing framework support
|
||||
- **Performance**: Optimized change detection and lazy loading
|
||||
- **Developer Experience**: Hot reload, TypeScript, and Angular DevTools
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
The Angular frontend can be served during development using `ng serve` and will proxy API calls to the backend.
|
||||
|
||||
### Production
|
||||
Build the application and serve the static files from any web server. Ensure the backend API is accessible from the same domain or configure CORS appropriately.
|
||||
|
||||
### Integration with Existing Backend
|
||||
The Angular frontend is designed to be a drop-in replacement for the static frontend. Simply:
|
||||
|
||||
1. Build the Angular app: `npm run build`
|
||||
2. Copy contents of `dist/frontend/` to your static files directory
|
||||
3. Update your backend to serve the new `index.html`
|
||||
4. Ensure API endpoints match the expected interface
|
||||
|
||||
## Browser Support
|
||||
- Chrome (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
- Edge (latest)
|
||||
|
||||
## Future Enhancements
|
||||
- Drag and drop file upload
|
||||
- Bulk operations
|
||||
- Advanced data visualization
|
||||
- Real-time updates via WebSocket
|
||||
- Export functionality
|
||||
- User authentication integration
|
||||
111
frontend/angular.json
Normal file
111
frontend/angular.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"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-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15273
frontend/package-lock.json
generated
Normal file
15273
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/material": "^19.2.19",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.17",
|
||||
"@angular/cli": "^19.2.17",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.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.7.2"
|
||||
}
|
||||
}
|
||||
11
frontend/proxy.conf.json
Normal file
11
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"pathRewrite": {
|
||||
"^/api": "/api"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
125
frontend/src/app/app.component.html
Normal file
125
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,125 @@
|
||||
<div class="app-container">
|
||||
<!-- Material Toolbar Header -->
|
||||
<mat-toolbar color="primary" class="app-toolbar">
|
||||
<mat-icon class="app-icon">storage</mat-icon>
|
||||
<span class="app-title">{{ title }}</span>
|
||||
<span class="spacer"></span>
|
||||
<div class="header-info" *ngIf="apiInfo">
|
||||
<mat-chip-set>
|
||||
<mat-chip>
|
||||
<mat-icon matChipAvatar>settings</mat-icon>
|
||||
{{ apiInfo.deployment_mode }}
|
||||
</mat-chip>
|
||||
<mat-chip>
|
||||
<mat-icon matChipAvatar>folder</mat-icon>
|
||||
{{ apiInfo.storage_backend }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<mat-tab-group [(selectedIndex)]="selectedTabIndex" class="main-tabs">
|
||||
<!-- Artifacts Tab -->
|
||||
<mat-tab label="Artifacts">
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content-wrapper">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<mat-icon>storage</mat-icon>
|
||||
Artifacts ({{ artifacts.length }})
|
||||
</h2>
|
||||
<button mat-raised-button color="primary" (click)="loadArtifacts()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="artifacts" class="artifacts-table mat-elevation-z4">
|
||||
<!-- ID Column -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let artifact">{{ artifact.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Filename Column -->
|
||||
<ng-container matColumnDef="filename">
|
||||
<th mat-header-cell *matHeaderCellDef>Filename</th>
|
||||
<td mat-cell *matCellDef="let artifact" class="filename-cell">
|
||||
<mat-icon class="file-icon">description</mat-icon>
|
||||
{{ artifact.filename }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="file_type">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<mat-chip class="type-chip">{{ artifact.file_type }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Size Column -->
|
||||
<ng-container matColumnDef="file_size">
|
||||
<th mat-header-cell *matHeaderCellDef>Size</th>
|
||||
<td mat-cell *matCellDef="let artifact">{{ formatBytes(artifact.file_size) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Test Name Column -->
|
||||
<ng-container matColumnDef="test_name">
|
||||
<th mat-header-cell *matHeaderCellDef>Test Name</th>
|
||||
<td mat-cell *matCellDef="let artifact">{{ artifact.test_name || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Test Result Column -->
|
||||
<ng-container matColumnDef="test_result">
|
||||
<th mat-header-cell *matHeaderCellDef>Result</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<mat-chip *ngIf="artifact.test_result" [class]="'result-' + artifact.test_result">
|
||||
{{ artifact.test_result }}
|
||||
</mat-chip>
|
||||
<span *ngIf="!artifact.test_result" class="text-muted">-</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Upload Tab -->
|
||||
<mat-tab label="Upload">
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content-wrapper">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Upload Artifacts
|
||||
</h2>
|
||||
</div>
|
||||
<app-upload-form (uploadSuccess)="onUploadSuccess()"></app-upload-form>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Query Tab -->
|
||||
<mat-tab label="Query">
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content-wrapper">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<mat-icon>search</mat-icon>
|
||||
Query Artifacts
|
||||
</h2>
|
||||
</div>
|
||||
<app-query-form
|
||||
(queryResults)="onQueryResults($event)"
|
||||
(filtersChange)="onFiltersChange($event)">
|
||||
</app-query-form>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
244
frontend/src/app/app.component.scss
Normal file
244
frontend/src/app/app.component.scss
Normal file
@@ -0,0 +1,244 @@
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.app-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
mat-chip-set {
|
||||
mat-chip {
|
||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||
color: white !important;
|
||||
|
||||
mat-icon {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab Group Styling
|
||||
.main-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-header {
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.mat-mdc-tab-labels {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 120px;
|
||||
height: 56px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab Content Wrapper
|
||||
.tab-content-wrapper {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
// Content Header
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
|
||||
mat-icon {
|
||||
color: #3f51b5;
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Artifacts Table Styling
|
||||
.artifacts-table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
th.mat-mdc-header-cell {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
td.mat-mdc-cell {
|
||||
padding: 16px 12px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
tr.mat-mdc-row {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.filename-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
|
||||
.file-icon {
|
||||
color: #3f51b5;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-chip {
|
||||
background-color: #e3f2fd !important;
|
||||
color: #1976d2 !important;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// Result Chips
|
||||
.result-pass {
|
||||
background-color: #e8f5e9 !important;
|
||||
color: #2e7d32 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-fail {
|
||||
background-color: #ffebee !important;
|
||||
color: #c62828 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-skip {
|
||||
background-color: #fff3e0 !important;
|
||||
color: #ef6c00 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
background-color: #fce4ec !important;
|
||||
color: #c2185b !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
.tab-content-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.artifacts-table {
|
||||
font-size: 12px;
|
||||
|
||||
th.mat-mdc-header-cell,
|
||||
td.mat-mdc-cell {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.artifacts-table {
|
||||
th.mat-mdc-header-cell:nth-child(n+5),
|
||||
td.mat-mdc-cell:nth-child(n+5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/app.component.spec.ts
Normal file
29
frontend/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'frontend' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('frontend');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
93
frontend/src/app/app.component.ts
Normal file
93
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
||||
import { QueryFormComponent } from './components/query-form/query-form.component';
|
||||
import { ApiService } from './services/api.service';
|
||||
import { ArtifactService } from './services/artifact.service';
|
||||
import { ApiInfo, Artifact } from './models/artifact.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatToolbarModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
UploadFormComponent,
|
||||
QueryFormComponent
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
title = 'Test Artifact Data Lake';
|
||||
apiInfo: ApiInfo | null = null;
|
||||
artifacts: Artifact[] = [];
|
||||
displayedColumns: string[] = ['id', 'filename', 'file_type', 'file_size', 'test_name', 'test_result'];
|
||||
selectedTabIndex = 0;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private artifactService: ArtifactService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadApiInfo();
|
||||
this.loadArtifacts();
|
||||
}
|
||||
|
||||
loadApiInfo(): void {
|
||||
this.apiService.getApiInfo().subscribe({
|
||||
next: (info) => {
|
||||
this.apiInfo = info;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading API info:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadArtifacts(): void {
|
||||
this.artifactService.getArtifacts().subscribe({
|
||||
next: (artifacts) => {
|
||||
console.log('Loaded artifacts:', artifacts.length);
|
||||
this.artifacts = artifacts;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading artifacts:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onUploadSuccess(): void {
|
||||
this.loadArtifacts();
|
||||
this.selectedTabIndex = 0; // Switch back to artifacts tab
|
||||
}
|
||||
|
||||
onQueryResults(artifacts: Artifact[]): void {
|
||||
this.artifacts = artifacts;
|
||||
this.selectedTabIndex = 0; // Switch to artifacts tab to show results
|
||||
}
|
||||
|
||||
onFiltersChange(filters: any): void {
|
||||
// Filters will be handled by the query component
|
||||
console.log('Filters changed:', filters);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
13
frontend/src/app/app.config.ts
Normal file
13
frontend/src/app/app.config.ts
Normal file
@@ -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()
|
||||
]
|
||||
};
|
||||
3
frontend/src/app/app.routes.ts
Normal file
3
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
@@ -0,0 +1,283 @@
|
||||
<div class="artifacts-section">
|
||||
<!-- Debug Loading State -->
|
||||
<div style="background: red; color: white; padding: 10px; margin: 10px;">
|
||||
LOADING STATE: {{ loading ? 'TRUE (Loading...)' : 'FALSE (Not Loading)' }}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<mat-card class="toolbar-card">
|
||||
<mat-card-content>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-buttons">
|
||||
<button mat-raised-button color="primary" (click)="loadArtifacts()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
<button mat-raised-button color="accent" (click)="generateSeedData()">
|
||||
<mat-icon>scatter_plot</mat-icon>
|
||||
Generate Seed Data
|
||||
</button>
|
||||
</div>
|
||||
<mat-chip-set class="count-chip">
|
||||
<mat-chip>
|
||||
<mat-icon matChipAvatar>storage</mat-icon>
|
||||
{{ filteredArtifacts.length }} artifacts
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div *ngIf="loading" class="loading-container">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
<p>Loading artifacts...</p>
|
||||
</div>
|
||||
|
||||
<!-- Simple List Test (No Material Components) -->
|
||||
<div *ngIf="!loading" style="background: lightgreen; padding: 20px; margin: 20px;">
|
||||
<h3>Simple List Test</h3>
|
||||
<p><strong>Filtered Artifacts Count:</strong> {{ filteredArtifacts.length }}</p>
|
||||
<div *ngFor="let artifact of filteredArtifacts.slice(0, 5)" style="border: 1px solid black; padding: 10px; margin: 5px;">
|
||||
<strong>ID:</strong> {{ artifact.id }} |
|
||||
<strong>Filename:</strong> {{ artifact.filename }} |
|
||||
<strong>Type:</strong> {{ artifact.file_type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Material Table -->
|
||||
<mat-card *ngIf="!loading" class="table-card">
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="filteredArtifacts" class="artifacts-table">
|
||||
|
||||
<!-- ID Column -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<strong>{{ artifact.id }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Event ID Column -->
|
||||
<ng-container matColumnDef="eventId">
|
||||
<th mat-header-cell *matHeaderCellDef>Event ID</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<mat-chip *ngIf="artifact.event_id" color="primary">
|
||||
{{ artifact.event_id }}
|
||||
</mat-chip>
|
||||
<span *ngIf="!artifact.event_id" class="text-muted">{{ artifact.id }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Filename Column -->
|
||||
<ng-container matColumnDef="filename">
|
||||
<th mat-header-cell *matHeaderCellDef>Filename</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<button mat-button (click)="showDetail(artifact)" class="filename-link">
|
||||
<mat-icon>description</mat-icon>
|
||||
{{ artifact.filename }}
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="type">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<mat-chip class="type-chip">{{ artifact.file_type }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Size Column -->
|
||||
<ng-container matColumnDef="size">
|
||||
<th mat-header-cell *matHeaderCellDef>Size</th>
|
||||
<td mat-cell *matCellDef="let artifact">{{ formatBytes(artifact.file_size) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Binaries Column -->
|
||||
<ng-container matColumnDef="binaries">
|
||||
<th mat-header-cell *matHeaderCellDef>Binaries</th>
|
||||
<td mat-cell *matCellDef="let artifact" class="binaries-cell">
|
||||
<div *ngIf="artifact.binaries && artifact.binaries.length > 0; else noBinaries">
|
||||
<mat-chip-set>
|
||||
<mat-chip *ngFor="let binary of getVisibleBinaries(artifact.binaries)" class="binary-chip">
|
||||
<mat-icon matChipAvatar>code</mat-icon>
|
||||
{{ binary }}
|
||||
</mat-chip>
|
||||
<mat-chip
|
||||
*ngIf="getHiddenBinariesCount(artifact.binaries) > 0"
|
||||
(click)="toggleBinariesExpansion(artifact.id)"
|
||||
class="expand-chip">
|
||||
<span *ngIf="!expandedBinaries[artifact.id]">
|
||||
+{{ getHiddenBinariesCount(artifact.binaries) }} more
|
||||
</span>
|
||||
<span *ngIf="expandedBinaries[artifact.id]">- less</span>
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
<mat-chip-set *ngIf="expandedBinaries[artifact.id]" class="expanded-binaries">
|
||||
<mat-chip *ngFor="let binary of artifact.binaries.slice(4)" class="binary-chip">
|
||||
<mat-icon matChipAvatar>code</mat-icon>
|
||||
{{ binary }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
<ng-template #noBinaries>
|
||||
<span class="text-muted">-</span>
|
||||
</ng-template>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Test Name Column -->
|
||||
<ng-container matColumnDef="testName">
|
||||
<th mat-header-cell *matHeaderCellDef>Test Name</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<span>{{ artifact.test_name || '-' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Suite Column -->
|
||||
<ng-container matColumnDef="suite">
|
||||
<th mat-header-cell *matHeaderCellDef>Suite</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<span>{{ artifact.test_suite || '-' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Result Column -->
|
||||
<ng-container matColumnDef="result">
|
||||
<th mat-header-cell *matHeaderCellDef>Result</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<mat-chip
|
||||
*ngIf="artifact.test_result"
|
||||
[class]="'result-' + artifact.test_result">
|
||||
<mat-icon matChipAvatar>{{ getResultIcon(artifact.test_result) }}</mat-icon>
|
||||
{{ artifact.test_result }}
|
||||
</mat-chip>
|
||||
<span *ngIf="!artifact.test_result" class="text-muted">-</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Tags Column -->
|
||||
<ng-container matColumnDef="tags">
|
||||
<th mat-header-cell *matHeaderCellDef>Tags</th>
|
||||
<td mat-cell *matCellDef="let artifact" class="tags-cell">
|
||||
<div *ngIf="artifact.tags && artifact.tags.length > 0; else noTags">
|
||||
<mat-chip-set>
|
||||
<mat-chip *ngFor="let tag of getVisibleTags(artifact.tags)" class="tag-chip">
|
||||
{{ tag }}
|
||||
</mat-chip>
|
||||
<mat-chip
|
||||
*ngIf="getHiddenTagsCount(artifact.tags) > 0"
|
||||
(click)="toggleTagsExpansion(artifact.id)"
|
||||
class="expand-chip">
|
||||
<span *ngIf="!expandedTags[artifact.id]">
|
||||
+{{ getHiddenTagsCount(artifact.tags) }} more
|
||||
</span>
|
||||
<span *ngIf="expandedTags[artifact.id]">- less</span>
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
<mat-chip-set *ngIf="expandedTags[artifact.id]" class="expanded-tags">
|
||||
<mat-chip *ngFor="let tag of artifact.tags.slice(3)" class="tag-chip">
|
||||
{{ tag }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
<app-tag-manager
|
||||
[artifactId]="artifact.id"
|
||||
[currentTags]="artifact.tags"
|
||||
(tagsUpdated)="onTagsUpdated()">
|
||||
</app-tag-manager>
|
||||
</div>
|
||||
<ng-template #noTags>
|
||||
<app-tag-manager
|
||||
[artifactId]="artifact.id"
|
||||
[currentTags]="[]"
|
||||
(tagsUpdated)="onTagsUpdated()">
|
||||
</app-tag-manager>
|
||||
</ng-template>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Created Column -->
|
||||
<ng-container matColumnDef="created">
|
||||
<th mat-header-cell *matHeaderCellDef>Created</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<small>{{ formatDate(artifact.created_at) }}</small>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let artifact">
|
||||
<div class="action-buttons">
|
||||
<button mat-icon-button
|
||||
(click)="downloadArtifact(artifact)"
|
||||
matTooltip="Download"
|
||||
color="primary">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button
|
||||
(click)="deleteArtifact(artifact)"
|
||||
matTooltip="Delete"
|
||||
color="warn">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Table Header and Rows -->
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- No Data Row -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell no-data" [attr.colspan]="displayedColumns.length">
|
||||
<div class="no-data-content">
|
||||
<mat-icon>inbox</mat-icon>
|
||||
<p>No artifacts found. Upload some files to get started!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Pagination -->
|
||||
<mat-card class="pagination-card" *ngIf="!loading">
|
||||
<mat-card-content>
|
||||
<div class="pagination">
|
||||
<button mat-icon-button
|
||||
(click)="previousPage()"
|
||||
[disabled]="currentPage === 1"
|
||||
matTooltip="Previous page">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<mat-chip>Page {{ currentPage }}</mat-chip>
|
||||
<button mat-icon-button
|
||||
(click)="nextPage()"
|
||||
[disabled]="filteredArtifacts.length < pageSize"
|
||||
matTooltip="Next page">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Artifact Detail Modal (placeholder for now) -->
|
||||
<div *ngIf="showDetailModal && selectedArtifact" class="detail-backdrop" (click)="closeDetailModal()">
|
||||
<mat-card class="detail-modal" (click)="$event.stopPropagation()">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Artifact Details</mat-card-title>
|
||||
<button mat-icon-button (click)="closeDetailModal()" class="close-button">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<!-- Detail content will be added later -->
|
||||
<p>Artifact ID: {{ selectedArtifact.id }}</p>
|
||||
<p>Filename: {{ selectedArtifact.filename }}</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,281 @@
|
||||
.artifacts-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.artifacts-table {
|
||||
width: 100%;
|
||||
|
||||
.mat-mdc-cell,
|
||||
.mat-mdc-header-cell {
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.mat-mdc-header-cell {
|
||||
font-weight: 600;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
// Column specific styles
|
||||
.binaries-cell,
|
||||
.tags-cell {
|
||||
max-width: 250px;
|
||||
|
||||
mat-chip-set {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filename-link {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
text-transform: none;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Chip styles
|
||||
.type-chip {
|
||||
background-color: #e3f2fd !important;
|
||||
color: #1976d2 !important;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.binary-chip {
|
||||
background-color: #f3e5f5 !important;
|
||||
color: #7b1fa2 !important;
|
||||
font-size: 10px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
background-color: #e8f5e8 !important;
|
||||
color: #2e7d32 !important;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.expand-chip {
|
||||
background-color: #fff3e0 !important;
|
||||
color: #f57c00 !important;
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #ffe0b2 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.expanded-binaries,
|
||||
.expanded-tags {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// Result chips
|
||||
.result-pass {
|
||||
background-color: #e8f5e8 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.result-fail {
|
||||
background-color: #ffebee !important;
|
||||
color: #d32f2f !important;
|
||||
}
|
||||
|
||||
.result-skip {
|
||||
background-color: #fff8e1 !important;
|
||||
color: #f57c00 !important;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
background-color: #fce4ec !important;
|
||||
color: #c2185b !important;
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// No data state
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px !important;
|
||||
}
|
||||
|
||||
.no-data-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #666;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// Detail modal
|
||||
.detail-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
margin-left: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.artifacts-table {
|
||||
font-size: 12px;
|
||||
|
||||
.mat-mdc-cell,
|
||||
.mat-mdc-header-cell {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.binaries-cell,
|
||||
.tags-cell {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
// Override Material styles
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mat-mdc-chip {
|
||||
--mdc-chip-container-height: 24px;
|
||||
--mdc-chip-with-avatar-container-height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mat-mdc-chip-set {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Component, OnInit, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { Artifact } from '../../models/artifact.interface';
|
||||
import { ArtifactService } from '../../services/artifact.service';
|
||||
import { NotificationService } from '../../services/notification.service';
|
||||
import { TagManagerComponent } from '../tag-manager/tag-manager.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-artifacts-table',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatCardModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
TagManagerComponent
|
||||
],
|
||||
templateUrl: './artifacts-table.component.html',
|
||||
styleUrl: './artifacts-table.component.scss'
|
||||
})
|
||||
export class ArtifactsTableComponent implements OnInit, OnChanges {
|
||||
@Input() artifacts: Artifact[] = [];
|
||||
@Input() filters: any = {};
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'eventId',
|
||||
'filename',
|
||||
'type',
|
||||
'size',
|
||||
'binaries',
|
||||
'testName',
|
||||
'suite',
|
||||
'result',
|
||||
'tags',
|
||||
'created',
|
||||
'actions'
|
||||
];
|
||||
|
||||
expandedBinaries: { [key: number]: boolean } = {};
|
||||
expandedTags: { [key: number]: boolean } = {};
|
||||
|
||||
currentPage = 1;
|
||||
pageSize = 25;
|
||||
loading = false; // Start with false to show content immediately
|
||||
|
||||
selectedArtifact: Artifact | null = null;
|
||||
showDetailModal = false;
|
||||
|
||||
filteredArtifacts: Artifact[] = [];
|
||||
|
||||
constructor(
|
||||
private artifactService: ArtifactService,
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
console.log('ArtifactsTableComponent ngOnInit - artifacts count:', this.artifacts.length);
|
||||
console.log('Initial loading state:', this.loading);
|
||||
// Always load artifacts on init
|
||||
this.loadArtifacts();
|
||||
// Force show after a delay to debug
|
||||
setTimeout(() => {
|
||||
console.log('Timeout - forcing loading to false');
|
||||
this.loading = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
console.log('ArtifactsTableComponent ngOnChanges - artifacts:', changes['artifacts']?.currentValue?.length || 0);
|
||||
// Re-apply filters when artifacts or filters input changes
|
||||
if (changes['artifacts'] || changes['filters']) {
|
||||
this.applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
loadArtifacts(): void {
|
||||
console.log('Loading artifacts...');
|
||||
this.loading = true;
|
||||
this.artifactService.getArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize)
|
||||
.subscribe({
|
||||
next: (artifacts) => {
|
||||
console.log('Loaded artifacts:', artifacts.length);
|
||||
this.artifacts = artifacts;
|
||||
this.applyFilters();
|
||||
this.loading = false;
|
||||
console.log('Loading complete. loading =', this.loading);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading artifacts:', error);
|
||||
this.loading = false;
|
||||
console.log('Error occurred. loading =', this.loading);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
console.log('Applying filters to', this.artifacts.length, 'artifacts');
|
||||
this.filteredArtifacts = this.artifacts.filter(artifact => {
|
||||
if (this.filters.filename && !artifact.filename.toLowerCase().includes(this.filters.filename.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (this.filters.fileType && artifact.file_type !== this.filters.fileType) {
|
||||
return false;
|
||||
}
|
||||
if (this.filters.testName && !artifact.test_name?.toLowerCase().includes(this.filters.testName.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (this.filters.testSuite && !artifact.test_suite?.toLowerCase().includes(this.filters.testSuite.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (this.filters.testResult && artifact.test_result !== this.filters.testResult) {
|
||||
return false;
|
||||
}
|
||||
if (this.filters.tags && this.filters.tags.length > 0) {
|
||||
const hasMatchingTag = this.filters.tags.some((tag: string) =>
|
||||
artifact.tags.some(artifactTag => artifactTag.toLowerCase().includes(tag.toLowerCase()))
|
||||
);
|
||||
if (!hasMatchingTag) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
console.log('Filtered artifacts count:', this.filteredArtifacts.length);
|
||||
}
|
||||
|
||||
toggleBinariesExpansion(artifactId: number): void {
|
||||
this.expandedBinaries[artifactId] = !this.expandedBinaries[artifactId];
|
||||
}
|
||||
|
||||
toggleTagsExpansion(artifactId: number): void {
|
||||
this.expandedTags[artifactId] = !this.expandedTags[artifactId];
|
||||
}
|
||||
|
||||
showDetail(artifact: Artifact): void {
|
||||
this.selectedArtifact = artifact;
|
||||
this.showDetailModal = true;
|
||||
}
|
||||
|
||||
closeDetailModal(): void {
|
||||
this.showDetailModal = false;
|
||||
this.selectedArtifact = null;
|
||||
}
|
||||
|
||||
downloadArtifact(artifact: Artifact): void {
|
||||
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: (error) => {
|
||||
console.error('Error downloading artifact:', error);
|
||||
this.notificationService.showError('Error downloading artifact: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteArtifact(artifact: Artifact): Promise<void> {
|
||||
const confirmed = await this.notificationService.showConfirmation(
|
||||
`Are you sure you want to delete "${artifact.filename}"? This cannot be undone.`,
|
||||
'Delete'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifactService.deleteArtifact(artifact.id).subscribe({
|
||||
next: () => {
|
||||
this.notificationService.showSuccess('Artifact deleted successfully');
|
||||
this.loadArtifacts();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error deleting artifact:', error);
|
||||
this.notificationService.showError('Error deleting artifact: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
generateSeedData(): void {
|
||||
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) {
|
||||
this.notificationService.showWarning('Please enter a number between 1 and 100');
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifactService.generateSeedData(num).subscribe({
|
||||
next: (result) => {
|
||||
this.notificationService.showSuccess(result.message || 'Seed data generated successfully');
|
||||
this.loadArtifacts();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error generating seed data:', error);
|
||||
this.notificationService.showError('Error generating seed data: ' + error.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 {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
getVisibleBinaries(binaries: string[] | undefined): string[] {
|
||||
if (!binaries) return [];
|
||||
return binaries.slice(0, 4);
|
||||
}
|
||||
|
||||
getHiddenBinariesCount(binaries: string[] | undefined): number {
|
||||
if (!binaries) return 0;
|
||||
return Math.max(0, binaries.length - 4);
|
||||
}
|
||||
|
||||
getVisibleTags(tags: string[]): string[] {
|
||||
return tags.slice(0, 3);
|
||||
}
|
||||
|
||||
getHiddenTagsCount(tags: string[]): number {
|
||||
return Math.max(0, tags.length - 3);
|
||||
}
|
||||
|
||||
previousPage(): void {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.loadArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
this.currentPage++;
|
||||
this.loadArtifacts();
|
||||
}
|
||||
|
||||
onTagsUpdated(): void {
|
||||
this.loadArtifacts();
|
||||
}
|
||||
|
||||
getResultIcon(result: string): string {
|
||||
switch (result) {
|
||||
case 'pass': return 'check_circle';
|
||||
case 'fail': return 'cancel';
|
||||
case 'skip': return 'skip_next';
|
||||
case 'error': return 'error';
|
||||
default: return 'help';
|
||||
}
|
||||
}
|
||||
}
|
||||
197
frontend/src/app/components/query-form/query-form.component.html
Normal file
197
frontend/src/app/components/query-form/query-form.component.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<mat-card class="query-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>search</mat-icon>
|
||||
Query Artifacts
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle>
|
||||
Search and filter your artifact collection
|
||||
</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form (ngSubmit)="queryArtifacts()" #queryFormRef="ngForm" class="query-form">
|
||||
<!-- Basic Search Section -->
|
||||
<div class="form-section">
|
||||
<h3>Basic Search</h3>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Filename</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="filename"
|
||||
[(ngModel)]="queryForm.filename"
|
||||
(input)="onFilterChange()"
|
||||
placeholder="Search filename...">
|
||||
<mat-icon matSuffix>description</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>File Type</mat-label>
|
||||
<mat-select
|
||||
name="file_type"
|
||||
[(ngModel)]="queryForm.file_type"
|
||||
(selectionChange)="onFilterChange()">
|
||||
<mat-option value="">All Types</mat-option>
|
||||
<mat-option *ngFor="let type of fileTypes" [value]="type">
|
||||
{{ type.toUpperCase() }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matSuffix>category</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Information Section -->
|
||||
<div class="form-section">
|
||||
<h3>Test Information</h3>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Test Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="test_name"
|
||||
[(ngModel)]="queryForm.test_name"
|
||||
(input)="onFilterChange()"
|
||||
placeholder="Search test name...">
|
||||
<mat-icon matSuffix>quiz</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Test Suite</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="test_suite"
|
||||
[(ngModel)]="queryForm.test_suite"
|
||||
(input)="onFilterChange()"
|
||||
placeholder="e.g., integration">
|
||||
<mat-icon matSuffix>folder</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Test Result</mat-label>
|
||||
<mat-select
|
||||
name="test_result"
|
||||
[(ngModel)]="queryForm.test_result"
|
||||
(selectionChange)="onFilterChange()">
|
||||
<mat-option value="">All Results</mat-option>
|
||||
<mat-option *ngFor="let result of testResults" [value]="result">
|
||||
<mat-icon>{{ getResultIcon(result) }}</mat-icon>
|
||||
{{ result | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matSuffix>assignment_turned_in</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Tags</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="tags"
|
||||
[(ngModel)]="tagsInput"
|
||||
(input)="onFilterChange()"
|
||||
placeholder="e.g., regression, smoke">
|
||||
<mat-icon matSuffix>label</mat-icon>
|
||||
<mat-hint>Comma-separated tags</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Section -->
|
||||
<div class="form-section">
|
||||
<h3>Date Range</h3>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Start Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="startPicker"
|
||||
name="start_date"
|
||||
[(ngModel)]="queryForm.start_date">
|
||||
<mat-datepicker-toggle matSuffix [for]="startPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #startPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="endPicker"
|
||||
name="end_date"
|
||||
[(ngModel)]="queryForm.end_date">
|
||||
<mat-datepicker-toggle matSuffix [for]="endPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #endPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Progress -->
|
||||
<div class="search-progress" *ngIf="searching">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Searching artifacts...</span>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="searching"
|
||||
class="search-button">
|
||||
<mat-icon>{{ searching ? 'hourglass_empty' : 'search' }}</mat-icon>
|
||||
{{ searching ? 'Searching...' : 'Search Artifacts' }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="clearQuery()"
|
||||
[disabled]="searching">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Active Filters Display -->
|
||||
<mat-card class="filters-card" *ngIf="queryForm.filename || queryForm.file_type || queryForm.test_name || queryForm.test_suite || queryForm.test_result || tagsInput">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Active Filters
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<mat-chip-set class="filter-chips">
|
||||
<mat-chip *ngIf="queryForm.filename" color="primary">
|
||||
<mat-icon matChipAvatar>description</mat-icon>
|
||||
Filename: {{ queryForm.filename }}
|
||||
</mat-chip>
|
||||
<mat-chip *ngIf="queryForm.file_type" color="accent">
|
||||
<mat-icon matChipAvatar>category</mat-icon>
|
||||
Type: {{ queryForm.file_type }}
|
||||
</mat-chip>
|
||||
<mat-chip *ngIf="queryForm.test_name" color="primary">
|
||||
<mat-icon matChipAvatar>quiz</mat-icon>
|
||||
Test: {{ queryForm.test_name }}
|
||||
</mat-chip>
|
||||
<mat-chip *ngIf="queryForm.test_suite" color="accent">
|
||||
<mat-icon matChipAvatar>folder</mat-icon>
|
||||
Suite: {{ queryForm.test_suite }}
|
||||
</mat-chip>
|
||||
<mat-chip *ngIf="queryForm.test_result" color="primary">
|
||||
<mat-icon matChipAvatar>assignment_turned_in</mat-icon>
|
||||
Result: {{ queryForm.test_result }}
|
||||
</mat-chip>
|
||||
<mat-chip *ngIf="tagsInput" color="accent">
|
||||
<mat-icon matChipAvatar>label</mat-icon>
|
||||
Tags: {{ tagsInput }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
207
frontend/src/app/components/query-form/query-form.component.scss
Normal file
207
frontend/src/app/components/query-form/query-form.component.scss
Normal file
@@ -0,0 +1,207 @@
|
||||
.query-card {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 16px 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #424242;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
color: #666;
|
||||
|
||||
mat-spinner {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
.search-button {
|
||||
padding: 12px 32px;
|
||||
font-size: 16px;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
mat-chip {
|
||||
--mdc-chip-container-height: 36px;
|
||||
|
||||
mat-icon[matChipAvatar] {
|
||||
background-color: transparent !important;
|
||||
color: currentColor !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Material Design overrides
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
|
||||
.mat-mdc-form-field-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-select-panel {
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
.mat-mdc-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-chip {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mat-mdc-raised-button {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-datepicker-toggle {
|
||||
.mat-icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
.mat-mdc-button-touch-target {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card title styling
|
||||
:host ::ng-deep .mat-mdc-card-header {
|
||||
.mat-mdc-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mat-mdc-card-subtitle {
|
||||
margin-top: 4px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.query-card,
|
||||
.filters-card {
|
||||
margin: 0 16px 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.search-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
gap: 6px;
|
||||
|
||||
mat-chip {
|
||||
--mdc-chip-container-height: 32px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.query-card,
|
||||
.filters-card {
|
||||
margin: 0 8px 12px;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
137
frontend/src/app/components/query-form/query-form.component.ts
Normal file
137
frontend/src/app/components/query-form/query-form.component.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Component, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { ArtifactQuery, Artifact } from '../../models/artifact.interface';
|
||||
import { ArtifactService } from '../../services/artifact.service';
|
||||
import { NotificationService } from '../../services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-query-form',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule
|
||||
],
|
||||
templateUrl: './query-form.component.html',
|
||||
styleUrl: './query-form.component.scss'
|
||||
})
|
||||
export class QueryFormComponent {
|
||||
@Output() queryResults = new EventEmitter<Artifact[]>();
|
||||
@Output() filtersChange = new EventEmitter<any>();
|
||||
|
||||
queryForm: ArtifactQuery = {
|
||||
filename: '',
|
||||
file_type: '',
|
||||
test_name: '',
|
||||
test_suite: '',
|
||||
test_result: '',
|
||||
tags: [],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
limit: 100,
|
||||
offset: 0
|
||||
};
|
||||
|
||||
searching = false;
|
||||
fileTypes = ['csv', 'json', 'binary', 'pcap'];
|
||||
testResults = ['pass', 'fail', 'skip', 'error'];
|
||||
|
||||
tagsInput = '';
|
||||
|
||||
constructor(
|
||||
private artifactService: ArtifactService,
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
queryArtifacts(): void {
|
||||
this.searching = true;
|
||||
|
||||
const query: ArtifactQuery = { ...this.queryForm };
|
||||
|
||||
if (this.tagsInput) {
|
||||
query.tags = this.tagsInput.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
|
||||
if (query.start_date) {
|
||||
query.start_date = new Date(query.start_date).toISOString();
|
||||
}
|
||||
|
||||
if (query.end_date) {
|
||||
query.end_date = new Date(query.end_date).toISOString();
|
||||
}
|
||||
|
||||
this.artifactService.queryArtifacts(query).subscribe({
|
||||
next: (artifacts) => {
|
||||
this.queryResults.emit(artifacts);
|
||||
this.searching = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Query failed:', error);
|
||||
this.notificationService.showError('Query failed: ' + error.message);
|
||||
this.searching = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearQuery(): void {
|
||||
this.queryForm = {
|
||||
filename: '',
|
||||
file_type: '',
|
||||
test_name: '',
|
||||
test_suite: '',
|
||||
test_result: '',
|
||||
tags: [],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
limit: 100,
|
||||
offset: 0
|
||||
};
|
||||
this.tagsInput = '';
|
||||
this.emitFilters();
|
||||
}
|
||||
|
||||
emitFilters(): void {
|
||||
const filters = {
|
||||
filename: this.queryForm.filename,
|
||||
fileType: this.queryForm.file_type,
|
||||
testName: this.queryForm.test_name,
|
||||
testSuite: this.queryForm.test_suite,
|
||||
testResult: this.queryForm.test_result,
|
||||
tags: this.tagsInput ? this.tagsInput.split(',').map(t => t.trim()).filter(t => t) : []
|
||||
};
|
||||
this.filtersChange.emit(filters);
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.emitFilters();
|
||||
}
|
||||
|
||||
getResultIcon(result: string): string {
|
||||
switch (result) {
|
||||
case 'pass': return 'check_circle';
|
||||
case 'fail': return 'cancel';
|
||||
case 'skip': return 'skip_next';
|
||||
case 'error': return 'error';
|
||||
default: return 'help';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<mat-tab-group
|
||||
[(selectedIndex)]="selectedIndex"
|
||||
(selectedTabChange)="onTabChange($event)"
|
||||
animationDuration="300ms"
|
||||
color="primary">
|
||||
|
||||
<!-- Artifacts Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">view_list</mat-icon>
|
||||
Artifacts
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Upload Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">cloud_upload</mat-icon>
|
||||
Upload
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Query Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">search</mat-icon>
|
||||
Query
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
@@ -0,0 +1,44 @@
|
||||
.tab-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-tab-group {
|
||||
.mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 120px;
|
||||
|
||||
.mat-mdc-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-tab {
|
||||
min-width: 80px;
|
||||
|
||||
.mat-mdc-tab-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export type TabType = 'artifacts' | 'upload' | 'query';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tab-navigation',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTabsModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './tab-navigation.component.html',
|
||||
styleUrl: './tab-navigation.component.scss'
|
||||
})
|
||||
export class TabNavigationComponent {
|
||||
@Output() tabChange = new EventEmitter<TabType>();
|
||||
|
||||
selectedIndex = 0;
|
||||
|
||||
tabs = [
|
||||
{ id: 'artifacts' as TabType, label: 'Artifacts', icon: 'view_list' },
|
||||
{ id: 'upload' as TabType, label: 'Upload', icon: 'cloud_upload' },
|
||||
{ id: 'query' as TabType, label: 'Query', icon: 'search' }
|
||||
];
|
||||
|
||||
onTabChange(event: any): void {
|
||||
const selectedTab = this.tabs[event.index];
|
||||
this.tabChange.emit(selectedTab.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<div class="tag-manager">
|
||||
<!-- Current Tags Display -->
|
||||
<div class="current-tags-section" *ngIf="currentTags.length > 0">
|
||||
<mat-chip-set>
|
||||
<mat-chip
|
||||
*ngFor="let tag of currentTags"
|
||||
class="current-tag"
|
||||
[removable]="true"
|
||||
(removed)="removeTag(tag)">
|
||||
<mat-icon matChipAvatar>label</mat-icon>
|
||||
{{ tag }}
|
||||
<mat-icon matChipRemove>cancel</mat-icon>
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<!-- Add Tag Button -->
|
||||
<div class="add-tag-section">
|
||||
<button
|
||||
mat-fab
|
||||
color="primary"
|
||||
class="add-tag-fab"
|
||||
(click)="toggleAddTag()"
|
||||
[matTooltip]="showAddTag ? 'Close tag manager' : 'Add new tag'">
|
||||
<mat-icon>{{ showAddTag ? 'close' : 'add' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Tag Form -->
|
||||
<mat-card class="add-tag-card" *ngIf="showAddTag">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>new_label</mat-icon>
|
||||
Add New Tag
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form class="tag-form">
|
||||
<!-- Tag Name Input -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Tag Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newTagName"
|
||||
name="tagName"
|
||||
placeholder="Enter tag name"
|
||||
(keyup.enter)="addTag()">
|
||||
<mat-icon matSuffix>label</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Scope Section -->
|
||||
<div class="scope-section">
|
||||
<button
|
||||
mat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
(click)="toggleScopeInput()">
|
||||
<mat-icon>{{ showScopeInput ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ showScopeInput ? 'Hide Scope Options' : 'Add Scope' }}
|
||||
</button>
|
||||
|
||||
<div class="scope-inputs" *ngIf="showScopeInput">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Predefined Scope</mat-label>
|
||||
<mat-select [(ngModel)]="newTagScope" name="scopeSelect">
|
||||
<mat-option value="">No scope</mat-option>
|
||||
<mat-option *ngFor="let scope of predefinedScopes" [value]="scope">
|
||||
{{ scope | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Custom Scope</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newTagScope"
|
||||
name="customScope"
|
||||
placeholder="Enter custom scope">
|
||||
<mat-icon matSuffix>category</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="addTag()"
|
||||
[disabled]="!newTagName.trim()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Tag
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
(click)="resetForm()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Available Tags (Quick Add) -->
|
||||
<mat-expansion-panel class="available-tags-panel" *ngIf="showAddTag && availableTags.length > 0">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>library_add</mat-icon>
|
||||
Quick Add Existing Tags
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
Click to add existing tags
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<!-- Unscoped Tags -->
|
||||
<div class="tag-group" *ngIf="getTagsByScope().length > 0">
|
||||
<h4>General Tags</h4>
|
||||
<mat-chip-set class="available-chip-set">
|
||||
<mat-chip
|
||||
*ngFor="let tag of getTagsByScope()"
|
||||
class="available-tag"
|
||||
[class.attached]="isTagAttached(tag.name)"
|
||||
[disabled]="isTagAttached(tag.name)"
|
||||
(click)="!isTagAttached(tag.name) && addExistingTag(tag)">
|
||||
{{ tag.name }}
|
||||
<mat-icon *ngIf="isTagAttached(tag.name)" matChipTrailingIcon>check</mat-icon>
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<!-- Scoped Tags -->
|
||||
<div class="tag-group" *ngFor="let scope of getUniqueScopes()">
|
||||
<h4>{{ scope | titlecase }} Tags</h4>
|
||||
<mat-chip-set class="available-chip-set">
|
||||
<mat-chip
|
||||
*ngFor="let tag of getTagsByScope(scope)"
|
||||
class="available-tag scoped"
|
||||
[class.attached]="isTagAttached(tag.name)"
|
||||
[disabled]="isTagAttached(tag.name)"
|
||||
(click)="!isTagAttached(tag.name) && addExistingTag(tag)">
|
||||
{{ tag.name }}
|
||||
<mat-icon *ngIf="isTagAttached(tag.name)" matChipTrailingIcon>check</mat-icon>
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</div>
|
||||
@@ -0,0 +1,198 @@
|
||||
.tag-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.current-tags-section {
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-chip-set {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.current-tag {
|
||||
background-color: #e3f2fd !important;
|
||||
color: #1976d2 !important;
|
||||
|
||||
mat-icon[matChipAvatar] {
|
||||
background-color: #1976d2 !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-tag-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.add-tag-fab {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-tag-card {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
|
||||
mat-card-header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scope-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.scope-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-tags-panel {
|
||||
margin-top: 16px;
|
||||
|
||||
.tag-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #424242;
|
||||
margin: 0 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: #2196f3;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.available-chip-set {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.available-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:not(.attached):hover {
|
||||
background-color: #e8f5e8 !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
&.attached {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.scoped {
|
||||
border-left: 3px solid #ff9800;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Material Design overrides
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-chip {
|
||||
--mdc-chip-container-height: 32px;
|
||||
font-size: 12px;
|
||||
|
||||
&.current-tag {
|
||||
--mdc-chip-with-avatar-container-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-chip-set {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mat-mdc-fab {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-expansion-panel-header {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mat-expansion-panel-body {
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.tag-manager {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.add-tag-card {
|
||||
margin: 0 8px;
|
||||
|
||||
.tag-form .scope-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-group {
|
||||
.available-chip-set {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.add-tag-card .tag-form .form-actions {
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
frontend/src/app/components/tag-manager/tag-manager.component.ts
Normal file
173
frontend/src/app/components/tag-manager/tag-manager.component.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { Tag } from '../../models/artifact.interface';
|
||||
import { ArtifactService } from '../../services/artifact.service';
|
||||
import { NotificationService } from '../../services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tag-manager',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatChipsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCardModule,
|
||||
MatTooltipModule,
|
||||
MatExpansionModule
|
||||
],
|
||||
templateUrl: './tag-manager.component.html',
|
||||
styleUrl: './tag-manager.component.scss'
|
||||
})
|
||||
export class TagManagerComponent implements OnInit {
|
||||
@Input() artifactId!: number;
|
||||
@Input() currentTags: string[] = [];
|
||||
@Output() tagsUpdated = new EventEmitter<void>();
|
||||
|
||||
availableTags: Tag[] = [];
|
||||
newTagName = '';
|
||||
newTagScope = '';
|
||||
showAddTag = false;
|
||||
showScopeInput = false;
|
||||
|
||||
predefinedScopes = ['project', 'environment', 'priority', 'category', 'status'];
|
||||
|
||||
constructor(
|
||||
private artifactService: ArtifactService,
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAvailableTags();
|
||||
}
|
||||
|
||||
loadAvailableTags(): void {
|
||||
this.artifactService.getAllTags().subscribe({
|
||||
next: (tags) => {
|
||||
this.availableTags = tags;
|
||||
},
|
||||
error: (error) => {
|
||||
// Tags endpoint not implemented yet - silently ignore
|
||||
if (error.status === 404) {
|
||||
console.log('Tags API not implemented yet');
|
||||
this.availableTags = [];
|
||||
} else {
|
||||
console.error('Error loading tags:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleAddTag(): void {
|
||||
this.showAddTag = !this.showAddTag;
|
||||
if (!this.showAddTag) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
toggleScopeInput(): void {
|
||||
this.showScopeInput = !this.showScopeInput;
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.newTagName = '';
|
||||
this.newTagScope = '';
|
||||
this.showScopeInput = false;
|
||||
}
|
||||
|
||||
addTag(): void {
|
||||
if (!this.newTagName.trim()) return;
|
||||
|
||||
const tag: Tag = {
|
||||
name: this.newTagName.trim(),
|
||||
scope: this.newTagScope.trim() || undefined
|
||||
};
|
||||
|
||||
this.artifactService.createTag(tag).subscribe({
|
||||
next: (createdTag) => {
|
||||
this.artifactService.addTag(this.artifactId, createdTag).subscribe({
|
||||
next: () => {
|
||||
this.loadAvailableTags();
|
||||
this.tagsUpdated.emit();
|
||||
this.resetForm();
|
||||
this.showAddTag = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error adding tag to artifact:', error);
|
||||
this.notificationService.showError('Error adding tag to artifact: ' + error.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error creating tag:', error);
|
||||
this.notificationService.showError('Error creating tag: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
const tagToRemove = this.availableTags.find(t => t.name === tag);
|
||||
if (!tagToRemove?.id) return;
|
||||
|
||||
this.artifactService.removeTag(this.artifactId, tagToRemove.id).subscribe({
|
||||
next: () => {
|
||||
this.tagsUpdated.emit();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error removing tag:', error);
|
||||
this.notificationService.showError('Error removing tag: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addExistingTag(tag: Tag): void {
|
||||
this.artifactService.addTag(this.artifactId, tag).subscribe({
|
||||
next: () => {
|
||||
this.tagsUpdated.emit();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error adding existing tag:', error);
|
||||
this.notificationService.showError('Error adding tag: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isTagAttached(tagName: string): boolean {
|
||||
return this.currentTags.includes(tagName);
|
||||
}
|
||||
|
||||
getTagsByScope(scope?: string): Tag[] {
|
||||
return this.availableTags.filter(tag => tag.scope === scope);
|
||||
}
|
||||
|
||||
getUniqueScopes(): string[] {
|
||||
const scopes = this.availableTags
|
||||
.map(tag => tag.scope)
|
||||
.filter((scope, index, arr) => scope && arr.indexOf(scope) === index) as string[];
|
||||
return scopes.sort();
|
||||
}
|
||||
|
||||
getTagColor(tag: Tag): string {
|
||||
if (tag.color) return tag.color;
|
||||
|
||||
const colors = ['#e0e7ff', '#fef3c7', '#d1fae5', '#fee2e2', '#f3e8ff', '#dbeafe'];
|
||||
const hash = tag.name.split('').reduce((a, b) => {
|
||||
a = ((a << 5) - a) + b.charCodeAt(0);
|
||||
return a & a;
|
||||
}, 0);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<mat-card class="upload-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Upload Artifact
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form (ngSubmit)="uploadArtifact()" #uploadForm="ngForm" class="upload-form">
|
||||
<!-- File Upload Section -->
|
||||
<div class="file-upload-section">
|
||||
<div class="file-input-container">
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
(change)="onFileSelected($event)"
|
||||
required
|
||||
style="display: none;">
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Select File</mat-label>
|
||||
<input matInput
|
||||
[value]="selectedFile?.name || ''"
|
||||
placeholder="No file selected"
|
||||
readonly>
|
||||
<button mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="fileInput.click()"
|
||||
[attr.aria-label]="'Select file'">
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
<mat-hint>Supported: CSV, JSON, binary files, PCAP</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div *ngIf="selectedFile" class="selected-file-info">
|
||||
<mat-chip-set>
|
||||
<mat-chip color="primary">
|
||||
<mat-icon matChipAvatar>description</mat-icon>
|
||||
{{ selectedFile.name }}
|
||||
</mat-chip>
|
||||
<mat-chip color="accent">
|
||||
<mat-icon matChipAvatar>data_usage</mat-icon>
|
||||
{{ selectedFile.size | number }} bytes
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event ID and Test Name -->
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Event ID</mat-label>
|
||||
<input matInput
|
||||
name="eventId"
|
||||
[(ngModel)]="formData.eventId"
|
||||
placeholder="e.g., EVENT_001">
|
||||
<mat-icon matSuffix>event</mat-icon>
|
||||
<mat-hint>Groups multiple artifacts under the same event</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Test Name</mat-label>
|
||||
<input matInput
|
||||
name="testName"
|
||||
[(ngModel)]="formData.testName"
|
||||
placeholder="e.g., login_test">
|
||||
<mat-icon matSuffix>quiz</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Test Suite and Result -->
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Test Suite</mat-label>
|
||||
<input matInput
|
||||
name="testSuite"
|
||||
[(ngModel)]="formData.testSuite"
|
||||
placeholder="e.g., integration">
|
||||
<mat-icon matSuffix>category</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Test Result</mat-label>
|
||||
<mat-select name="testResult" [(ngModel)]="formData.testResult">
|
||||
<mat-option value="">-- Select --</mat-option>
|
||||
<mat-option *ngFor="let result of testResults" [value]="result">
|
||||
<mat-icon>{{ getResultIcon(result) }}</mat-icon>
|
||||
{{ result | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matSuffix>assignment_turned_in</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Version and Binaries -->
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Version</mat-label>
|
||||
<input matInput
|
||||
name="version"
|
||||
[(ngModel)]="formData.version"
|
||||
placeholder="e.g., v1.0.0">
|
||||
<mat-icon matSuffix>tag</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Associated Binaries</mat-label>
|
||||
<input matInput
|
||||
name="binaries"
|
||||
[(ngModel)]="formData.binaries"
|
||||
placeholder="e.g., app.exe, lib.dll, config.json">
|
||||
<mat-icon matSuffix>code</mat-icon>
|
||||
<mat-hint>Comma-separated list of binaries/files</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Tags</mat-label>
|
||||
<input matInput
|
||||
name="tags"
|
||||
[(ngModel)]="formData.tags"
|
||||
placeholder="e.g., regression, smoke, critical">
|
||||
<mat-icon matSuffix>label</mat-icon>
|
||||
<mat-hint>Comma-separated tags</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Description -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput
|
||||
name="description"
|
||||
[(ngModel)]="formData.description"
|
||||
rows="3"
|
||||
placeholder="Describe this artifact..."></textarea>
|
||||
<mat-icon matSuffix>description</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- JSON Fields -->
|
||||
<div class="json-section">
|
||||
<h3>Advanced Configuration</h3>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Test Config (JSON)</mat-label>
|
||||
<textarea matInput
|
||||
name="testConfig"
|
||||
[(ngModel)]="formData.testConfig"
|
||||
rows="4"
|
||||
placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
|
||||
<mat-icon matSuffix>settings</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Custom Metadata (JSON)</mat-label>
|
||||
<textarea matInput
|
||||
name="customMetadata"
|
||||
[(ngModel)]="formData.customMetadata"
|
||||
rows="4"
|
||||
placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
|
||||
<mat-icon matSuffix>data_object</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<mat-progress-bar *ngIf="uploading"
|
||||
mode="indeterminate"
|
||||
class="upload-progress"></mat-progress-bar>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="uploading || !selectedFile"
|
||||
class="upload-button">
|
||||
<mat-icon>{{ uploading ? 'hourglass_empty' : 'cloud_upload' }}</mat-icon>
|
||||
{{ uploading ? 'Uploading...' : 'Upload Artifact' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div *ngIf="uploadStatus" class="status-section">
|
||||
<mat-chip-set>
|
||||
<mat-chip [class]="uploadStatusType">
|
||||
<mat-icon matChipAvatar>
|
||||
{{ uploadStatusType === 'success' ? 'check_circle' : 'error' }}
|
||||
</mat-icon>
|
||||
{{ uploadStatus }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,124 @@
|
||||
.upload-card {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-upload-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.file-input-container {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-file-info {
|
||||
margin-top: 12px;
|
||||
|
||||
mat-chip-set {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.json-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #424242;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
padding: 12px 32px;
|
||||
font-size: 16px;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-section {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
mat-chip-set {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #ffcdd2 !important;
|
||||
color: #d32f2f !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Material Design overrides
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mat-mdc-chip {
|
||||
--mdc-chip-container-height: 28px;
|
||||
}
|
||||
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
margin: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.upload-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
176
frontend/src/app/components/upload-form/upload-form.component.ts
Normal file
176
frontend/src/app/components/upload-form/upload-form.component.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Component, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ArtifactService } from '../../services/artifact.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload-form',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressBarModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
templateUrl: './upload-form.component.html',
|
||||
styleUrl: './upload-form.component.scss'
|
||||
})
|
||||
export class UploadFormComponent {
|
||||
@Output() uploadSuccess = new EventEmitter<void>();
|
||||
|
||||
selectedFile: File | null = null;
|
||||
uploading = false;
|
||||
uploadStatus = '';
|
||||
uploadStatusType: 'success' | 'error' | '' = '';
|
||||
|
||||
formData = {
|
||||
testName: '',
|
||||
testSuite: '',
|
||||
testResult: '',
|
||||
version: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
testConfig: '',
|
||||
customMetadata: '',
|
||||
eventId: '',
|
||||
binaries: ''
|
||||
};
|
||||
|
||||
testResults = ['pass', 'fail', 'skip', 'error'];
|
||||
|
||||
constructor(private artifactService: ArtifactService) {}
|
||||
|
||||
onFileSelected(event: any): void {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.selectedFile = file;
|
||||
}
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.selectedFile = null;
|
||||
this.formData = {
|
||||
testName: '',
|
||||
testSuite: '',
|
||||
testResult: '',
|
||||
version: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
testConfig: '',
|
||||
customMetadata: '',
|
||||
eventId: '',
|
||||
binaries: ''
|
||||
};
|
||||
|
||||
const fileInput = document.getElementById('file') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
showUploadStatus(message: string, success: boolean): void {
|
||||
this.uploadStatus = message;
|
||||
this.uploadStatusType = success ? 'success' : 'error';
|
||||
|
||||
setTimeout(() => {
|
||||
this.uploadStatus = '';
|
||||
this.uploadStatusType = '';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
uploadArtifact(): void {
|
||||
if (!this.selectedFile) {
|
||||
this.showUploadStatus('Please select a file to upload', false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedFile);
|
||||
|
||||
const fields = ['testName', 'testSuite', 'testResult', 'version', 'description', 'eventId'];
|
||||
fields.forEach(field => {
|
||||
const key = field === 'testName' ? 'test_name' :
|
||||
field === 'testSuite' ? 'test_suite' :
|
||||
field === 'testResult' ? 'test_result' :
|
||||
field === 'eventId' ? 'event_id' : field;
|
||||
|
||||
const value = this.formData[field as keyof typeof this.formData];
|
||||
if (value) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.formData.tags) {
|
||||
const tagsArray = this.formData.tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
formData.append('tags', JSON.stringify(tagsArray));
|
||||
}
|
||||
|
||||
if (this.formData.binaries) {
|
||||
const binariesArray = this.formData.binaries.split(',').map(b => b.trim()).filter(b => b);
|
||||
formData.append('binaries', JSON.stringify(binariesArray));
|
||||
}
|
||||
|
||||
if (this.formData.testConfig) {
|
||||
try {
|
||||
JSON.parse(this.formData.testConfig);
|
||||
formData.append('test_config', this.formData.testConfig);
|
||||
} catch (e) {
|
||||
this.showUploadStatus('Invalid Test Config JSON', false);
|
||||
this.uploading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.formData.customMetadata) {
|
||||
try {
|
||||
JSON.parse(this.formData.customMetadata);
|
||||
formData.append('custom_metadata', this.formData.customMetadata);
|
||||
} catch (e) {
|
||||
this.showUploadStatus('Invalid Custom Metadata JSON', false);
|
||||
this.uploading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.artifactService.uploadArtifact(formData).subscribe({
|
||||
next: (response) => {
|
||||
this.showUploadStatus(`Successfully uploaded: ${response.filename}`, true);
|
||||
this.resetForm();
|
||||
this.uploadSuccess.emit();
|
||||
this.uploading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Upload error:', error);
|
||||
this.showUploadStatus('Upload failed: ' + (error.error?.detail || error.message), false);
|
||||
this.uploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getResultIcon(result: string): string {
|
||||
switch (result) {
|
||||
case 'pass': return 'check_circle';
|
||||
case 'fail': return 'cancel';
|
||||
case 'skip': return 'skip_next';
|
||||
case 'error': return 'error';
|
||||
default: return 'help';
|
||||
}
|
||||
}
|
||||
}
|
||||
51
frontend/src/app/models/artifact.interface.ts
Normal file
51
frontend/src/app/models/artifact.interface.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
filename: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
storage_path: string;
|
||||
test_name?: string;
|
||||
test_suite?: string;
|
||||
test_result?: 'pass' | 'fail' | 'skip' | 'error';
|
||||
test_config?: any;
|
||||
custom_metadata?: any;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
version?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
event_id?: string;
|
||||
binaries?: string[];
|
||||
}
|
||||
|
||||
export interface ArtifactQuery {
|
||||
filename?: string;
|
||||
file_type?: string;
|
||||
test_name?: string;
|
||||
test_suite?: string;
|
||||
test_result?: string;
|
||||
tags?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ApiInfo {
|
||||
deployment_mode: string;
|
||||
storage_backend: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
id: number;
|
||||
filename: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id?: number;
|
||||
name: string;
|
||||
scope?: string;
|
||||
color?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
18
frontend/src/app/services/api.service.ts
Normal file
18
frontend/src/app/services/api.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiInfo } from '../models/artifact.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
// Use relative URL - proxy will forward to backend
|
||||
private readonly API_BASE = '';
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
getApiInfo(): Observable<ApiInfo> {
|
||||
return this.http.get<ApiInfo>(`${this.API_BASE}/api`);
|
||||
}
|
||||
}
|
||||
72
frontend/src/app/services/artifact.service.ts
Normal file
72
frontend/src/app/services/artifact.service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, BehaviorSubject } from 'rxjs';
|
||||
import { Artifact, ArtifactQuery, UploadResponse, Tag } from '../models/artifact.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ArtifactService {
|
||||
// Use relative URL - proxy will forward to backend
|
||||
private readonly API_BASE = '/api/v1';
|
||||
private artifactsSubject = new BehaviorSubject<Artifact[]>([]);
|
||||
public artifacts$ = this.artifactsSubject.asObservable();
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
getArtifacts(limit: number = 25, offset: number = 0): Observable<Artifact[]> {
|
||||
const params = new HttpParams()
|
||||
.set('limit', limit.toString())
|
||||
.set('offset', offset.toString());
|
||||
|
||||
return this.http.get<Artifact[]>(`${this.API_BASE}/artifacts/`, { params });
|
||||
}
|
||||
|
||||
getArtifact(id: number): Observable<Artifact> {
|
||||
return this.http.get<Artifact>(`${this.API_BASE}/artifacts/${id}`);
|
||||
}
|
||||
|
||||
uploadArtifact(formData: FormData): Observable<UploadResponse> {
|
||||
return this.http.post<UploadResponse>(`${this.API_BASE}/artifacts/upload`, formData);
|
||||
}
|
||||
|
||||
deleteArtifact(id: number): Observable<any> {
|
||||
return this.http.delete(`${this.API_BASE}/artifacts/${id}`);
|
||||
}
|
||||
|
||||
downloadArtifact(id: number): Observable<Blob> {
|
||||
return this.http.get(`${this.API_BASE}/artifacts/${id}/download`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
queryArtifacts(query: ArtifactQuery): Observable<Artifact[]> {
|
||||
return this.http.post<Artifact[]>(`${this.API_BASE}/artifacts/query`, query);
|
||||
}
|
||||
|
||||
generateSeedData(count: number): Observable<any> {
|
||||
return this.http.post(`${this.API_BASE}/seed/generate/${count}`, {});
|
||||
}
|
||||
|
||||
updateArtifactsCache(artifacts: Artifact[]): void {
|
||||
this.artifactsSubject.next(artifacts);
|
||||
}
|
||||
|
||||
getCurrentArtifacts(): Artifact[] {
|
||||
return this.artifactsSubject.value;
|
||||
}
|
||||
|
||||
addTag(artifactId: number, tag: Tag): Observable<any> {
|
||||
return this.http.post(`${this.API_BASE}/artifacts/${artifactId}/tags`, tag);
|
||||
}
|
||||
|
||||
removeTag(artifactId: number, tagId: number): Observable<any> {
|
||||
return this.http.delete(`${this.API_BASE}/artifacts/${artifactId}/tags/${tagId}`);
|
||||
}
|
||||
|
||||
getAllTags(): Observable<Tag[]> {
|
||||
return this.http.get<Tag[]>(`${this.API_BASE}/tags`);
|
||||
}
|
||||
|
||||
createTag(tag: Tag): Observable<Tag> {
|
||||
return this.http.post<Tag>(`${this.API_BASE}/tags`, tag);
|
||||
}
|
||||
}
|
||||
64
frontend/src/app/services/notification.service.ts
Normal file
64
frontend/src/app/services/notification.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NotificationService {
|
||||
|
||||
constructor(private snackBar: MatSnackBar) {}
|
||||
|
||||
showSuccess(message: string, duration: number = 3000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['success-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showError(message: string, duration: number = 5000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['error-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showInfo(message: string, duration: number = 3000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['info-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showWarning(message: string, duration: number = 4000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['warning-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showConfirmation(message: string, action: string = 'Confirm'): Promise<boolean> {
|
||||
const snackBarRef = this.snackBar.open(message, action, {
|
||||
duration: 10000,
|
||||
panelClass: ['confirmation-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
snackBarRef.onAction().subscribe(() => resolve(true));
|
||||
snackBarRef.afterDismissed().subscribe((info) => {
|
||||
if (!info.dismissedByAction) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
15
frontend/src/index.html
Normal file
15
frontend/src/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frontend</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
709
frontend/src/styles.scss
Normal file
709
frontend/src/styles.scss
Normal file
@@ -0,0 +1,709 @@
|
||||
/* Global styles for the Test Artifact Data Lake Angular app with Material Design */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Roboto, "Helvetica Neue", sans-serif;
|
||||
background: linear-gradient(135deg, #3f51b5 0%, #9c27b0 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 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: #f7f9fc;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 16px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-bottom: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
background: #f0f9ff;
|
||||
color: #0369a1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 14px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
padding: 40px !important;
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-pass {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.result-fail {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.result-skip {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
background: #fecaca;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
margin: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: #c7d2fe;
|
||||
}
|
||||
|
||||
.tag.removable {
|
||||
background: #f87171;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag.removable:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.tag.available {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tag.available:hover:not(.attached) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.tag.attached {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-type-badge {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
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: #64748b;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
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 #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
small {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
margin-top: 20px;
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-status.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-status.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selected-file {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
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.3);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f7f9fc;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.filename-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.filename-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Binaries and Tags cells */
|
||||
.binaries-cell, .tags-cell {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.binaries-list, .tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.binary-item {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.expand-btn:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.expanded-binaries, .expanded-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Tag Manager */
|
||||
.tag-manager {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.add-tag-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-tag-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.add-tag-btn.active {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.add-tag-form {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.tag-input, .scope-select, .scope-custom-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scope-toggle-btn {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scope-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.available-tags {
|
||||
margin-top: 16px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.available-tags h4 {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-group h5 {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attached-indicator {
|
||||
margin-left: 4px;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.filter-info {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-info h4 {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.binaries-cell, .tags-cell {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
||||
|
||||
/* Material Snackbar Styles */
|
||||
.success-snackbar {
|
||||
background-color: #4caf50 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.error-snackbar {
|
||||
background-color: #f44336 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.info-snackbar {
|
||||
background-color: #2196f3 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.warning-snackbar {
|
||||
background-color: #ff9800 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.confirmation-snackbar {
|
||||
background-color: #673ab7 !important;
|
||||
color: white !important;
|
||||
}
|
||||
15
frontend/tsconfig.app.json
Normal file
15
frontend/tsconfig.app.json
Normal file
@@ -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": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
/* 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": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
15
frontend/tsconfig.spec.json
Normal file
15
frontend/tsconfig.spec.json
Normal file
@@ -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/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user