Switch to angular

This commit is contained in:
pratik
2025-10-14 23:32:38 -05:00
parent 1ce27976a9
commit 82e8cea256
72 changed files with 20815 additions and 991 deletions

View File

@@ -0,0 +1,34 @@
{
"permissions": {
"allow": [
"Bash(npx:*)",
"Bash(npm run build:*)",
"Bash(ng add:*)",
"Bash(npm run start:*)",
"Bash(chmod:*)",
"Bash(pkill:*)",
"Bash(./quickstart.sh:*)",
"Bash(docker-compose:*)",
"Bash(timeout:*)",
"Bash(curl:*)",
"Bash(quickstart.bat --help)",
"Bash(powershell:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(taskkill:*)",
"Bash(Get-Process -Name \"node\" -ErrorAction SilentlyContinue)",
"Bash(Stop-Process -Force)",
"Bash(python -m uvicorn:*)",
"Bash(docker compose:*)",
"Bash(docker:*)",
"Bash(npm start)",
"Bash(python:*)",
"Bash(ng serve:*)",
"Bash(if exist .angular rmdir /s /q .angular)",
"Bash(dir:*)",
"Bash(tree:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,3 +1,4 @@
# Python cache
__pycache__
*.pyc
*.pyo
@@ -5,15 +6,50 @@ __pycache__
.Python
env/
venv/
# Node modules (critical for fixing esbuild platform issue)
node_modules
frontend/node_modules
*/node_modules
# Build outputs
frontend/dist
frontend/.angular
# Environment variables
*.env
.env
# Git
.git
.gitignore
# Documentation
*.md
# IDE files
.vscode
.idea
# Logs
*.log
# OS files
.DS_Store
# Configuration
helm/
.gitlab-ci.yml
docker-compose.yml
# Development files
frontend/.vscode
frontend/src/**/*.spec.ts
# Runtime data
*.pid
*.seed
*.pid.lock
# Coverage
coverage

View File

@@ -17,7 +17,6 @@ COPY app/ ./app/
COPY utils/ ./utils/
COPY alembic/ ./alembic/
COPY alembic.ini .
COPY static/ ./static/
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app

38
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,38 @@
# Multi-stage build for Angular frontend
FROM node:18-alpine as frontend-builder
# Install dependencies for native modules
RUN apk add --no-cache python3 make g++
WORKDIR /frontend
# Copy package files first for better layer caching
COPY frontend/package*.json ./
# Clean install dependencies with explicit platform targeting
# This ensures esbuild and other native modules are built for Alpine Linux
RUN npm ci --force
# Copy frontend source (excluding node_modules via .dockerignore)
COPY frontend/src ./src
COPY frontend/public ./public
COPY frontend/angular.json ./
COPY frontend/tsconfig*.json ./
# Build the Angular app for production
RUN npm run build --verbose
# Production image with nginx
FROM nginx:alpine
# Copy built Angular app
COPY --from=frontend-builder /frontend/dist/frontend /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,14 @@
# Simple approach - build on host and copy dist folder
FROM nginx:alpine
# Copy pre-built Angular app
COPY frontend/dist/frontend /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -38,17 +38,12 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
**Linux/macOS:**
```bash
./quickstart.sh
./scripts/quickstart.sh
```
**Windows (PowerShell):**
```powershell
.\quickstart.ps1
```
**Windows (Command Prompt):**
```batch
quickstart.bat
.\scripts\quickstart.ps1
```
### Manual Setup with Docker Compose
@@ -306,6 +301,26 @@ alembic upgrade head
- Verify `MINIO_ENDPOINT` is correct
- Check MinIO credentials
## Documentation
Detailed documentation is available in the `docs/` folder:
- **[Quick Start Guide](docs/QUICKSTART.md)** - Get started in minutes
- **[API Documentation](docs/API.md)** - Complete API reference
- **[Architecture](docs/ARCHITECTURE.md)** - System design and architecture
- **[Features](docs/FEATURES.md)** - Detailed feature descriptions
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Production deployment instructions
- **[Frontend Setup](docs/FRONTEND_SETUP.md)** - Angular frontend setup
- **[Frontend Usage](docs/FRONTEND_USAGE.md)** - Using the web UI
## Scripts
Helper scripts are available in the `scripts/` folder:
- **`quickstart.sh` / `quickstart.ps1`** - Quick start with Docker Compose
- **`quickstart-build.sh`** - Quick start with image rebuild
- **`dev-start.sh` / `dev-start.ps1`** - Start development environment
## License
[Your License Here]

117
app/api/tags.py Normal file
View File

@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.models.tag import Tag
from app.schemas.tag import TagCreate, TagUpdate, TagResponse
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
@router.post("/", response_model=TagResponse, status_code=201)
async def create_tag(tag: TagCreate, db: Session = Depends(get_db)):
"""
Create a new tag
- **name**: Tag name (unique, required)
- **description**: Tag description (optional)
- **color**: Hex color code (optional, e.g., #FF5733)
"""
# Check if tag already exists
existing_tag = db.query(Tag).filter(Tag.name == tag.name).first()
if existing_tag:
raise HTTPException(status_code=400, detail=f"Tag with name '{tag.name}' already exists")
db_tag = Tag(**tag.model_dump())
db.add(db_tag)
db.commit()
db.refresh(db_tag)
return db_tag
@router.get("/", response_model=List[TagResponse])
async def list_tags(
limit: int = Query(default=100, le=1000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db)
):
"""List all tags with pagination"""
tags = db.query(Tag).order_by(Tag.name).offset(offset).limit(limit).all()
return tags
@router.get("/{tag_id}", response_model=TagResponse)
async def get_tag(tag_id: int, db: Session = Depends(get_db)):
"""Get tag by ID"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag
@router.get("/name/{tag_name}", response_model=TagResponse)
async def get_tag_by_name(tag_name: str, db: Session = Depends(get_db)):
"""Get tag by name"""
tag = db.query(Tag).filter(Tag.name == tag_name).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag
@router.put("/{tag_id}", response_model=TagResponse)
async def update_tag(tag_id: int, tag_update: TagUpdate, db: Session = Depends(get_db)):
"""
Update a tag
- **name**: Tag name (optional)
- **description**: Tag description (optional)
- **color**: Hex color code (optional)
"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
# Check if new name conflicts with existing tag
if tag_update.name and tag_update.name != tag.name:
existing_tag = db.query(Tag).filter(Tag.name == tag_update.name).first()
if existing_tag:
raise HTTPException(status_code=400, detail=f"Tag with name '{tag_update.name}' already exists")
# Update fields
update_data = tag_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tag, field, value)
db.commit()
db.refresh(tag)
return tag
@router.delete("/{tag_id}")
async def delete_tag(tag_id: int, db: Session = Depends(get_db)):
"""Delete a tag"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
db.delete(tag)
db.commit()
return {"message": f"Tag '{tag.name}' deleted successfully"}
@router.post("/search", response_model=List[TagResponse])
async def search_tags(
query: str = Query(..., min_length=1, description="Search query"),
limit: int = Query(default=100, le=1000),
db: Session = Depends(get_db)
):
"""Search tags by name or description"""
tags = db.query(Tag).filter(
(Tag.name.ilike(f"%{query}%")) | (Tag.description.ilike(f"%{query}%"))
).order_by(Tag.name).limit(limit).all()
return tags

View File

@@ -1,7 +1,8 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.config import settings
from app.models.artifact import Base
from app.models.artifact import Base as ArtifactBase
from app.models.tag import Base as TagBase
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -9,7 +10,8 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db():
"""Initialize database tables"""
Base.metadata.create_all(bind=engine)
ArtifactBase.metadata.create_all(bind=engine)
TagBase.metadata.create_all(bind=engine)
def get_db():

View File

@@ -1,9 +1,8 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.api.artifacts import router as artifacts_router
from app.api.seed import router as seed_router
from app.api.tags import router as tags_router
from app.database import init_db
from app.config import settings
import logging
@@ -38,11 +37,9 @@ app.add_middleware(
# Include routers
app.include_router(artifacts_router)
app.include_router(seed_router)
app.include_router(tags_router)
# Mount static files
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
if os.path.exists(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
# Note: Frontend is now served separately as an Angular application
@app.on_event("startup")
@@ -69,19 +66,15 @@ async def api_root():
@app.get("/")
async def ui_root():
"""Serve the UI"""
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
else:
return {
"message": "Test Artifact Data Lake API",
"version": "1.0.0",
"docs": "/docs",
"ui": "UI not found. Serving API only.",
"deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend
}
"""API root - Frontend is served separately"""
return {
"message": "Test Artifact Data Lake API",
"version": "1.0.0",
"docs": "/docs",
"frontend": "Frontend is served separately on port 4200 (development) or via reverse proxy (production)",
"deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend
}
@app.get("/health")

21
app/models/tag.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlalchemy import Column, String, Integer, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
class Tag(Base):
__tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False, index=True)
description = Column(Text)
color = Column(String(7)) # Hex color code, e.g., #FF5733
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<Tag(id={self.id}, name='{self.name}')>"

28
app/schemas/tag.py Normal file
View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
class TagBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Tag name")
description: Optional[str] = Field(None, description="Tag description")
color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$", description="Hex color code (e.g., #FF5733)")
class TagCreate(TagBase):
pass
class TagUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Tag name")
description: Optional[str] = Field(None, description="Tag description")
color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$", description="Hex color code")
class TagResponse(TagBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,77 @@
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: datalake
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://user:password@postgres:5432/datalake
STORAGE_BACKEND: minio
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET_NAME: test-artifacts
MINIO_SECURE: "false"
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "80:80"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
volumes:
postgres_data:
minio_data:

View File

@@ -57,6 +57,9 @@ services:
timeout: 10s
retries: 3
# Frontend service removed from default compose - use dev-start.sh for development
# For production with built frontend, use: docker-compose -f docker-compose.production.yml up
volumes:
postgres_data:
minio_data:

View File

114
docs/FRONTEND_USAGE.md Normal file
View File

@@ -0,0 +1,114 @@
# Frontend Usage Guide
The Test Artifact Data Lake now features a modern Angular frontend with Material Design components. This guide explains how to run the application in different modes.
## Quick Start Options
### 1. Development Mode (Recommended for Development)
**Hot reload enabled, fastest for development**
**Linux/macOS:**
```bash
./dev-start.sh
```
**Windows:**
```batch
dev-start.bat
```
- Backend services: `http://localhost:8000`
- Frontend: `http://localhost:4200` (with hot reload)
- API Docs: `http://localhost:8000/docs`
- MinIO Console: `http://localhost:9001`
### 2. Production Mode (Complete Docker Stack)
**Pre-built frontend served via Nginx**
**Linux/macOS:**
```bash
./quickstart-build.sh
```
**Windows:** (Manual steps)
```batch
cd frontend
npm install
npm run build
cd ..
docker-compose -f docker-compose.production.yml up -d
```
- Complete application: `http://localhost:80`
- API (proxied): `http://localhost:80/api/`
- API Docs: `http://localhost:80/docs`
- MinIO Console: `http://localhost:9001`
### 3. Backend Only Mode
**For API-only usage or custom frontend setup**
**Any platform:**
```bash
./quickstart.sh # Linux/macOS
quickstart.bat # Windows
quickstart.ps1 # PowerShell
```
- Backend API: `http://localhost:8000`
- API Docs: `http://localhost:8000/docs`
- MinIO Console: `http://localhost:9001`
## Technical Details
### Architecture
- **Frontend**: Angular 19 with Angular Material Design
- **Backend**: FastAPI with PostgreSQL and MinIO
- **Development**: Frontend dev server + Backend containers
- **Production**: Nginx serving Angular + Backend containers
### Ports
- `80` - Production frontend (Nginx)
- `4200` - Development frontend (Angular dev server)
- `8000` - Backend API (FastAPI)
- `5432` - PostgreSQL database
- `9000` - MinIO storage
- `9001` - MinIO console
### Development Workflow
1. Use `dev-start.sh` or `dev-start.bat` for daily development
2. Frontend changes automatically reload at `http://localhost:4200`
3. Backend API available at `http://localhost:8000`
4. Use browser dev tools for debugging
### Production Deployment
1. Build frontend: `npm run build` in `frontend/` directory
2. Use `docker-compose.production.yml` for complete stack
3. Nginx proxies API requests to backend
4. Static assets served efficiently by Nginx
## Troubleshooting
### Frontend Build Issues
If you encounter esbuild platform errors:
1. Delete `frontend/node_modules`
2. Run `npm install` in `frontend/` directory
3. Try the development mode first: `./dev-start.sh`
### Port Conflicts
- Development: Change Angular port in `angular.json`
- Production: Modify `docker-compose.production.yml` ports
### Docker Issues
- Ensure Docker Desktop is running
- Try `docker-compose down` and restart
- Check logs: `docker-compose logs -f api`
## Features
- Modern Material Design interface
- Responsive design for mobile/tablet
- File upload with drag-and-drop
- Advanced search and filtering
- Tag management system
- Real-time notifications
- Data visualization
- Export capabilities

195
docs/QUICKSTART.md Normal file
View File

@@ -0,0 +1,195 @@
# Quick Start Guide
## Overview
The Test Artifact Data Lake platform provides several ways to run the application depending on your use case:
- **Development**: Backend services in Docker + Frontend dev server with hot reload
- **Production**: Complete stack in Docker containers
- **Testing**: Various rebuild and cleanup options
## Prerequisites
- **Docker Desktop** (Windows/macOS) or **Docker** + **Docker Compose** (Linux)
- **Node.js 18+** (for development mode)
- **npm** (for development mode)
## Quick Start Options
### 1. Development Mode (Recommended for Development)
**Linux/macOS:**
```bash
./quickstart.sh # Backend services only
./dev-start.sh # Backend + Frontend dev server
```
**Windows:**
```powershell
.\quickstart.ps1 # Backend services only
.\quickstart.ps1 -FullStack # Complete stack
.\quickstart.ps1 -Rebuild # Rebuild containers
.\dev-start.ps1 # Backend + Frontend dev server
```
**URLs:**
- Frontend: http://localhost:4200 (with hot reload)
- API: http://localhost:8000
- API Docs: http://localhost:8000/docs
- MinIO Console: http://localhost:9001
### 2. Production Mode (Complete Stack)
**Linux/macOS:**
```bash
./quickstart.sh --full-stack
```
**Windows:**
```cmd
.\quickstart.ps1 -FullStack
```
**URLs:**
- Frontend: http://localhost:80 (production build)
- API: http://localhost:8000
- MinIO Console: http://localhost:9001
### 3. Force Rebuild (When Code Changes)
**Linux/macOS:**
```bash
./quickstart.sh --rebuild # Rebuild backend only
./quickstart.sh --rebuild --full-stack # Rebuild complete stack
```
**Windows:**
```cmd
.\quickstart.ps1 -Rebuild # Rebuild backend only
.\quickstart.ps1 -Rebuild -FullStack # Rebuild complete stack
```
## Detailed Usage
### Development Workflow
1. **Start backend services:**
```bash
./quickstart.sh
```
2. **Start frontend in development mode:**
```bash
./dev-start.sh
```
Or manually:
```bash
cd frontend
npm install
npm run start
```
3. **Make changes to your code** - Frontend will auto-reload
4. **When backend code changes:**
```bash
./quickstart.sh --rebuild
```
### Production Testing
1. **Build and run complete stack:**
```bash
./quickstart.sh --full-stack
```
2. **Test at http://localhost:80**
3. **When code changes:**
```bash
./quickstart.sh --rebuild --full-stack
```
## Command Reference
### quickstart.sh / quickstart.ps1
| Option | Description |
|--------|-------------|
| (none) | Start backend services only (default) |
| `--full-stack` | Start complete stack including frontend |
| `--rebuild` | Force rebuild of containers |
| `--help` | Show help message |
### dev-start.sh / dev-start.ps1
Starts backend services + frontend development server with hot reload.
## Stopping Services
**Backend only:**
```bash
docker-compose down
```
**Complete stack:**
```bash
docker-compose -f docker-compose.production.yml down
```
## Logs
**Backend services:**
```bash
docker-compose logs -f
```
**Complete stack:**
```bash
docker-compose -f docker-compose.production.yml logs -f
```
**Specific service:**
```bash
docker-compose logs -f api
docker-compose logs -f postgres
docker-compose logs -f minio
```
## Environment Variables
Copy `.env.example` to `.env` and modify as needed:
```bash
cp .env.example .env
```
The quickstart scripts will automatically create this file if it doesn't exist.
## Troubleshooting
### Container Issues
- **Force rebuild:** Use `--rebuild` flag
- **Clean everything:** `docker-compose down --volumes --rmi all`
- **Check Docker:** Ensure Docker Desktop is running
### Frontend Issues
- **Dependencies:** Run `npm install` in `frontend/` directory
- **Port conflicts:** Check if port 4200 is available
- **Node version:** Ensure Node.js 18+ is installed
### Backend Issues
- **API not responding:** Wait longer for services to start (can take 30+ seconds)
- **Database issues:** Check `docker-compose logs postgres`
- **Storage issues:** Check `docker-compose logs minio`
## Development vs Production
| Feature | Development | Production |
|---------|-------------|------------|
| Frontend | Hot reload dev server | Built Angular app |
| Port | 4200 | 80 |
| Build time | Fast startup | Slower (builds Angular) |
| Use case | Development, testing | Demo, staging |
Choose the mode that best fits your workflow!

0
docs/todos.md Normal file
View File

17
frontend/.editorconfig Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View 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;
}
}
}

View 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');
});
});

View 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];
}
}

View 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()
]
};

View File

@@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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';
}
}
}

View 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>

View 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;
}
}

View 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';
}
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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%;
}
}
}

View 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];
}
}

View File

@@ -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>

View File

@@ -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%;
}
}

View 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';
}
}
}

View 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;
}

View 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`);
}
}

View 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);
}
}

View 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
View 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
View 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));

View File

@@ -1,3 +1,5 @@
/* Global styles for the Test Artifact Data Lake Angular app with Material Design */
* {
margin: 0;
padding: 0;
@@ -5,8 +7,8 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: Roboto, "Helvetica Neue", sans-serif;
background: linear-gradient(135deg, #3f51b5 0%, #9c27b0 100%);
min-height: 100vh;
padding: 20px;
}
@@ -141,6 +143,11 @@ header h1 {
font-size: 16px;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.count-badge {
background: #f0f9ff;
color: #0369a1;
@@ -227,6 +234,36 @@ tbody tr:hover {
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 {
@@ -268,6 +305,12 @@ tbody tr:hover {
gap: 20px;
}
.form-actions {
display: flex;
gap: 10px;
align-items: center;
}
label {
display: block;
font-weight: 500;
@@ -305,25 +348,34 @@ small {
margin-top: 4px;
}
#upload-status {
.upload-status {
margin-top: 20px;
padding: 14px;
border-radius: 6px;
display: none;
}
#upload-status.success {
.upload-status.success {
background: #d1fae5;
color: #065f46;
display: block;
}
#upload-status.error {
.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;
@@ -415,6 +467,194 @@ pre {
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;
@@ -433,4 +673,37 @@ pre {
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;
}

View 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
View 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
}
}

View 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"
]
}

76
nginx.conf Normal file
View File

@@ -0,0 +1,76 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name localhost;
# Serve Angular app
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Proxy docs requests to backend
location /docs {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy redoc requests to backend
location /redoc {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy health check to backend
location /health {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

View File

@@ -1,106 +0,0 @@
@echo off
setlocal enabledelayedexpansion
echo =========================================
echo Test Artifact Data Lake - Quick Start
echo =========================================
echo.
REM Check if Docker is installed
where docker >nul 2>nul
if %errorlevel% neq 0 (
echo Error: Docker is not installed. Please install Docker Desktop first.
echo Visit: https://www.docker.com/products/docker-desktop
pause
exit /b 1
)
REM Check if Docker Compose is available
where docker-compose >nul 2>nul
if %errorlevel% neq 0 (
REM Try docker compose (new version)
docker compose version >nul 2>nul
if %errorlevel% neq 0 (
echo Error: Docker Compose is not available.
echo Please ensure Docker Desktop is running.
pause
exit /b 1
)
set COMPOSE_CMD=docker compose
) else (
set COMPOSE_CMD=docker-compose
)
REM Create .env file if it doesn't exist
if not exist .env (
echo Creating .env file from .env.example...
copy .env.example .env >nul
echo [OK] .env file created
) else (
echo [OK] .env file already exists
)
echo.
echo Starting services with Docker Compose...
%COMPOSE_CMD% up -d
if %errorlevel% neq 0 (
echo.
echo Error: Failed to start services.
echo Make sure Docker Desktop is running.
pause
exit /b 1
)
echo.
echo Waiting for services to be ready...
timeout /t 15 /nobreak >nul
echo.
echo =========================================
echo Services are running!
echo =========================================
echo.
echo Web UI: http://localhost:8000
echo API Docs: http://localhost:8000/docs
echo MinIO Console: http://localhost:9001
echo Username: minioadmin
echo Password: minioadmin
echo.
echo To view logs: %COMPOSE_CMD% logs -f
echo To stop: %COMPOSE_CMD% down
echo.
echo =========================================
echo Testing the API...
echo =========================================
echo.
REM Wait a bit more for API to be fully ready
timeout /t 5 /nobreak >nul
REM Test health endpoint
curl -s http://localhost:8000/health | findstr "healthy" >nul 2>nul
if %errorlevel% equ 0 (
echo [OK] API is healthy!
echo.
echo =========================================
echo Open your browser to get started:
echo http://localhost:8000
echo =========================================
) else (
echo [WARNING] API is not responding yet.
echo Please wait a moment and check http://localhost:8000
)
echo.
echo =========================================
echo Setup complete!
echo =========================================
echo.
echo Press any key to open the UI in your browser...
pause >nul
REM Open browser
start http://localhost:8000
exit /b 0

View File

@@ -1,129 +0,0 @@
# Test Artifact Data Lake - Quick Start (PowerShell)
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Test Artifact Data Lake - Quick Start" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
# Check if Docker is installed
try {
$dockerVersion = docker --version
Write-Host "[OK] Docker found: $dockerVersion" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Docker is not installed." -ForegroundColor Red
Write-Host "Please install Docker Desktop first:" -ForegroundColor Yellow
Write-Host "https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
# Determine Docker Compose command
$composeCmd = "docker-compose"
try {
docker-compose version | Out-Null
} catch {
# Try new docker compose syntax
try {
docker compose version | Out-Null
$composeCmd = "docker compose"
} catch {
Write-Host "[ERROR] Docker Compose is not available." -ForegroundColor Red
Write-Host "Please ensure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
}
Write-Host "[OK] Using: $composeCmd" -ForegroundColor Green
# Create .env file if it doesn't exist
if (-Not (Test-Path ".env")) {
Write-Host "Creating .env file from .env.example..." -ForegroundColor Yellow
Copy-Item .env.example .env
Write-Host "[OK] .env file created" -ForegroundColor Green
} else {
Write-Host "[OK] .env file already exists" -ForegroundColor Green
}
Write-Host ""
Write-Host "Starting services with Docker Compose..." -ForegroundColor Yellow
# Start services
if ($composeCmd -eq "docker-compose") {
docker-compose up -d
} else {
docker compose up -d
}
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "[ERROR] Failed to start services." -ForegroundColor Red
Write-Host "Make sure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
Write-Host ""
Write-Host "Waiting for services to be ready..." -ForegroundColor Yellow
Start-Sleep -Seconds 15
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Services are running!" -ForegroundColor Green
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Web UI: " -NoNewline
Write-Host "http://localhost:8000" -ForegroundColor Yellow
Write-Host "API Docs: " -NoNewline
Write-Host "http://localhost:8000/docs" -ForegroundColor Yellow
Write-Host "MinIO Console: " -NoNewline
Write-Host "http://localhost:9001" -ForegroundColor Yellow
Write-Host " Username: minioadmin"
Write-Host " Password: minioadmin"
Write-Host ""
Write-Host "To view logs: $composeCmd logs -f" -ForegroundColor Cyan
Write-Host "To stop: $composeCmd down" -ForegroundColor Cyan
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Testing the API..." -ForegroundColor Yellow
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
# Wait a bit more for API
Start-Sleep -Seconds 5
# Test health endpoint
try {
$response = Invoke-WebRequest -Uri "http://localhost:8000/health" -UseBasicParsing -TimeoutSec 5
if ($response.Content -like "*healthy*") {
Write-Host "[OK] API is healthy!" -ForegroundColor Green
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Opening browser..." -ForegroundColor Yellow
Write-Host "http://localhost:8000" -ForegroundColor Yellow
Write-Host "=========================================" -ForegroundColor Cyan
# Open browser
Start-Process "http://localhost:8000"
}
} catch {
Write-Host "[WARNING] API is not responding yet." -ForegroundColor Yellow
Write-Host "Please wait a moment and check http://localhost:8000" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Setup complete! " -NoNewline
Write-Host "🚀" -ForegroundColor Green
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Useful Commands:" -ForegroundColor Cyan
Write-Host " Generate seed data: " -NoNewline
Write-Host "Use the 'Generate Seed Data' button in the UI" -ForegroundColor Yellow
Write-Host " View logs: " -NoNewline
Write-Host "$composeCmd logs -f api" -ForegroundColor Yellow
Write-Host " Restart services: " -NoNewline
Write-Host "$composeCmd restart" -ForegroundColor Yellow
Write-Host " Stop all: " -NoNewline
Write-Host "$composeCmd down" -ForegroundColor Yellow
Write-Host ""

156
scripts/dev-start.ps1 Normal file
View File

@@ -0,0 +1,156 @@
[CmdletBinding()]
param(
[switch]$Help
)
$ErrorActionPreference = "Stop"
if ($Help) {
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Test Artifact Data Lake - Development Setup" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Usage: .\dev-start.ps1 [OPTIONS]" -ForegroundColor White
Write-Host ""
Write-Host "Options:" -ForegroundColor Yellow
Write-Host " -Help Show this help message" -ForegroundColor White
Write-Host ""
Write-Host "This script starts backend services and frontend development server." -ForegroundColor Green
Write-Host ""
exit 0
}
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Test Artifact Data Lake - Development Setup" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
# Check if Node.js is installed
if (-not (Get-Command "node" -ErrorAction SilentlyContinue)) {
Write-Host "Error: Node.js is not installed. Please install Node.js 18+ first." -ForegroundColor Red
Write-Host "Visit: https://nodejs.org/" -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
# Check Node.js version
try {
$nodeVersion = & node --version
$majorVersion = [int]($nodeVersion -replace 'v(\d+)\..*', '$1')
if ($majorVersion -lt 18) {
Write-Host "Error: Node.js version 18 or higher is required. Current version: $nodeVersion" -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
}
Write-Host "[OK] Node.js version: $nodeVersion" -ForegroundColor Green
}
catch {
Write-Host "Error: Failed to check Node.js version" -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
}
# Check if npm is installed
if (-not (Get-Command "npm" -ErrorAction SilentlyContinue)) {
Write-Host "Error: npm is not installed. Please install npm first." -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
}
$npmVersion = & npm --version
Write-Host "[OK] npm version: $npmVersion" -ForegroundColor Green
# Check if Docker is installed
if (-not (Get-Command "docker" -ErrorAction SilentlyContinue)) {
Write-Host "Error: Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
Write-Host "Visit: https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
# Check if Docker Compose is available
$ComposeCmd = $null
if (Get-Command "docker-compose" -ErrorAction SilentlyContinue) {
$ComposeCmd = "docker-compose"
} else {
try {
& docker compose version | Out-Null
$ComposeCmd = "docker compose"
}
catch {
Write-Host "Error: Docker Compose is not available." -ForegroundColor Red
Write-Host "Please ensure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
}
Write-Host "[OK] Docker Compose command: $ComposeCmd" -ForegroundColor Green
Write-Host ""
# Create .env file if it doesn't exist
if (-not (Test-Path ".env")) {
Write-Host "Creating .env file from .env.example..." -ForegroundColor Yellow
Copy-Item ".env.example" ".env"
Write-Host "[OK] .env file created" -ForegroundColor Green
} else {
Write-Host "[OK] .env file already exists" -ForegroundColor Green
}
Write-Host ""
Write-Host "Starting backend services (PostgreSQL, MinIO, API)..." -ForegroundColor Yellow
# Start backend services
try {
if ($ComposeCmd -eq "docker compose") {
& docker compose up -d postgres minio api
} else {
& docker-compose up -d postgres minio api
}
}
catch {
Write-Host "Error: Failed to start backend services." -ForegroundColor Red
Write-Host "Make sure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
Write-Host ""
Write-Host "Waiting for backend services to be ready..." -ForegroundColor Yellow
Start-Sleep -Seconds 10
Write-Host ""
Write-Host "Installing frontend dependencies..." -ForegroundColor Yellow
Set-Location "frontend"
try {
& npm install
Write-Host "[OK] Frontend dependencies installed" -ForegroundColor Green
}
catch {
Write-Host "Error: Failed to install frontend dependencies" -ForegroundColor Red
Set-Location ".."
Read-Host "Press Enter to exit"
exit 1
}
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Development Environment Ready!" -ForegroundColor Green
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Backend API: http://localhost:8000" -ForegroundColor White
Write-Host "API Docs: http://localhost:8000/docs" -ForegroundColor White
Write-Host "MinIO Console: http://localhost:9001" -ForegroundColor White
Write-Host " Username: minioadmin" -ForegroundColor Gray
Write-Host " Password: minioadmin" -ForegroundColor Gray
Write-Host ""
Write-Host "Frontend will be available at: http://localhost:4200" -ForegroundColor White
Write-Host ""
Write-Host "To view backend logs: $ComposeCmd logs -f api" -ForegroundColor Yellow
Write-Host "To stop backend: $ComposeCmd down" -ForegroundColor Yellow
Write-Host ""
Write-Host "Starting frontend development server..." -ForegroundColor Green
# Start the frontend development server
& npm run start

89
scripts/dev-start.sh Normal file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
set -e
echo "========================================="
echo "Test Artifact Data Lake - Development Setup"
echo "========================================="
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "Error: Node.js is not installed. Please install Node.js 18+ first."
exit 1
fi
# Check Node.js version
NODE_VERSION=$(node --version | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt "18" ]; then
echo "Error: Node.js version 18 or higher is required. Current version: $(node --version)"
exit 1
fi
# Check if npm is installed
if ! command -v npm &> /dev/null; then
echo "Error: npm is not installed. Please install npm first."
exit 1
fi
# Check if Docker is installed for backend services
if ! command -v docker &> /dev/null; then
echo "Error: Docker is not installed. Please install Docker first."
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null; then
echo "Error: Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
echo "✓ Node.js version: $(node --version)"
echo "✓ npm version: $(npm --version)"
echo "✓ Docker version: $(docker --version)"
echo ""
# Create .env file if it doesn't exist
if [ ! -f .env ]; then
echo "Creating .env file from .env.example..."
cp .env.example .env
echo "✓ .env file created"
else
echo "✓ .env file already exists"
fi
echo ""
echo "Starting backend services (PostgreSQL, MinIO, API)..."
docker-compose up -d postgres minio api
echo ""
echo "Waiting for backend services to be ready..."
sleep 10
echo ""
echo "Installing frontend dependencies..."
cd frontend
npm install
echo ""
echo "========================================="
echo "Development Environment Ready!"
echo "========================================="
echo ""
echo "Backend API: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"
echo "MinIO Console: http://localhost:9001"
echo " Username: minioadmin"
echo " Password: minioadmin"
echo ""
echo "To start the frontend development server:"
echo " cd frontend"
echo " npm run start"
echo ""
echo "Frontend will be available at: http://localhost:4200"
echo ""
echo "To view backend logs: docker-compose logs -f api"
echo "To stop backend: docker-compose down"
echo ""
echo "Starting frontend development server..."
npm run start

79
quickstart.sh → scripts/quickstart-build.sh Executable file → Normal file
View File

@@ -3,10 +3,16 @@
set -e
echo "========================================="
echo "Test Artifact Data Lake - Quick Start"
echo "Test Artifact Data Lake - Production Build"
echo "========================================="
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "Error: Node.js is not installed. Please install Node.js 18+ first."
exit 1
fi
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "Error: Docker is not installed. Please install Docker first."
@@ -29,52 +35,71 @@ else
fi
echo ""
echo "Starting services with Docker Compose..."
docker-compose up -d
echo "========================================="
echo "Building Angular Frontend..."
echo "========================================="
echo ""
cd frontend
# Install dependencies if node_modules doesn't exist
if [ ! -d "node_modules" ]; then
echo "Installing frontend dependencies..."
npm install
fi
# Build the Angular app for production
echo "Building Angular app for production..."
npm run build
if [ $? -ne 0 ]; then
echo "Error: Frontend build failed. Please check the errors above."
exit 1
fi
echo "✓ Frontend built successfully"
cd ..
echo ""
echo "========================================="
echo "Starting Docker Services..."
echo "========================================="
echo ""
docker-compose -f docker-compose.production.yml up -d
echo ""
echo "Waiting for services to be ready..."
sleep 10
sleep 15
echo ""
echo "========================================="
echo "Services are running!"
echo "========================================="
echo ""
echo "Frontend (UI): http://localhost:80"
echo "API: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"
echo "MinIO Console: http://localhost:9001"
echo " Username: minioadmin"
echo " Password: minioadmin"
echo ""
echo "To view logs: docker-compose logs -f"
echo "To stop: docker-compose down"
echo "To view logs: docker-compose -f docker-compose.production.yml logs -f"
echo "To stop: docker-compose -f docker-compose.production.yml down"
echo ""
echo "========================================="
echo "Testing the API..."
echo "========================================="
echo "NOTE: The main application UI is now available at http://localhost:80"
echo " This is an Angular application with Material Design components."
echo ""
# Wait a bit more for API to be fully ready
sleep 5
# Test health endpoint
sleep 5
if curl -s http://localhost:8000/health | grep -q "healthy"; then
echo "✓ API is healthy!"
echo ""
echo "Example: Upload a test file"
echo "----------------------------"
echo 'echo "test,data" > test.csv'
echo 'curl -X POST "http://localhost:8000/api/v1/artifacts/upload" \'
echo ' -F "file=@test.csv" \'
echo ' -F "test_name=sample_test" \'
echo ' -F "test_suite=demo" \'
echo ' -F "test_result=pass"'
echo ""
echo "========================================="
echo "Setup complete! 🚀"
echo "========================================="
else
echo "⚠ API is not responding yet. Please wait a moment and check http://localhost:8000/health"
fi
echo "========================================="
echo "Setup complete! 🚀"
echo "========================================="
echo "⚠ API is not responding yet. Please wait a moment and check http://localhost:80"
fi

213
scripts/quickstart.ps1 Normal file
View File

@@ -0,0 +1,213 @@
[CmdletBinding()]
param(
[switch]$Rebuild,
[switch]$FullStack,
[switch]$Dev,
[switch]$Help
)
$ErrorActionPreference = "Stop"
if ($Help) {
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Test Artifact Data Lake - Quick Start" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Usage: .\quickstart.ps1 [OPTIONS]" -ForegroundColor White
Write-Host ""
Write-Host "Options:" -ForegroundColor Yellow
Write-Host " -Rebuild Force rebuild of all containers" -ForegroundColor White
Write-Host " -FullStack Start complete stack including frontend (production build)" -ForegroundColor White
Write-Host " -Dev Start backend only, use separate dev server for frontend" -ForegroundColor White
Write-Host " -Help Show this help message" -ForegroundColor White
Write-Host ""
Write-Host "Default: Starts backend services only (recommended for development)" -ForegroundColor Green
Write-Host ""
exit 0
}
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Test Artifact Data Lake - Quick Start" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
# Check if Docker is installed
if (-not (Get-Command "docker" -ErrorAction SilentlyContinue)) {
Write-Host "Error: Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
Write-Host "Visit: https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
# Check if Docker Compose is available
$ComposeCmd = $null
if (Get-Command "docker-compose" -ErrorAction SilentlyContinue) {
$ComposeCmd = "docker-compose"
} else {
try {
& docker compose version | Out-Null
$ComposeCmd = "docker compose"
}
catch {
Write-Host "Error: Docker Compose is not available." -ForegroundColor Red
Write-Host "Please ensure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
}
}
# Create .env file if it doesn't exist
if (-not (Test-Path ".env")) {
Write-Host "Creating .env file from .env.example..." -ForegroundColor Yellow
Copy-Item ".env.example" ".env"
Write-Host "[OK] .env file created" -ForegroundColor Green
} else {
Write-Host "[OK] .env file already exists" -ForegroundColor Green
}
Write-Host ""
# Handle rebuild logic
if ($Rebuild) {
Write-Host "Rebuilding containers..." -ForegroundColor Yellow
Write-Host "Stopping existing containers..." -ForegroundColor White
if ($FullStack) {
if ($ComposeCmd -eq "docker compose") {
& docker compose -f "docker-compose.production.yml" down
Write-Host "Removing existing images for full rebuild..." -ForegroundColor White
& docker compose -f "docker-compose.production.yml" down --rmi local 2>$null
Write-Host "Building and starting full stack..." -ForegroundColor White
& docker compose -f "docker-compose.production.yml" up -d --build
} else {
& docker-compose -f "docker-compose.production.yml" down
Write-Host "Removing existing images for full rebuild..." -ForegroundColor White
& docker-compose -f "docker-compose.production.yml" down --rmi local 2>$null
Write-Host "Building and starting full stack..." -ForegroundColor White
& docker-compose -f "docker-compose.production.yml" up -d --build
}
} else {
if ($ComposeCmd -eq "docker compose") {
& docker compose down
Write-Host "Removing existing backend images for rebuild..." -ForegroundColor White
& docker compose down --rmi local 2>$null
Write-Host "Building and starting backend services..." -ForegroundColor White
& docker compose up -d --build
} else {
& docker-compose down
Write-Host "Removing existing backend images for rebuild..." -ForegroundColor White
& docker-compose down --rmi local 2>$null
Write-Host "Building and starting backend services..." -ForegroundColor White
& docker-compose up -d --build
}
}
} else {
# Regular startup
if ($FullStack) {
Write-Host "Starting full production stack (backend + frontend)..." -ForegroundColor Green
if ($ComposeCmd -eq "docker compose") {
& docker compose -f "docker-compose.production.yml" up -d
} else {
& docker-compose -f "docker-compose.production.yml" up -d
}
} else {
Write-Host "Starting backend services..." -ForegroundColor Green
if ($ComposeCmd -eq "docker compose") {
& docker compose up -d
} else {
& docker-compose up -d
}
}
}
Write-Host ""
Write-Host "Waiting for services to be ready..." -ForegroundColor Yellow
Start-Sleep -Seconds 15
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
if ($FullStack) {
Write-Host "Complete Stack is running!" -ForegroundColor Green
} else {
Write-Host "Backend Services are running!" -ForegroundColor Green
}
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "API: http://localhost:8000" -ForegroundColor White
Write-Host "API Docs: http://localhost:8000/docs" -ForegroundColor White
Write-Host "MinIO Console: http://localhost:9001" -ForegroundColor White
Write-Host " Username: minioadmin" -ForegroundColor Gray
Write-Host " Password: minioadmin" -ForegroundColor Gray
Write-Host ""
if ($FullStack) {
Write-Host "Frontend: http://localhost:80" -ForegroundColor White
Write-Host " (Production build with Nginx)" -ForegroundColor Gray
Write-Host ""
Write-Host "To view logs: $ComposeCmd -f docker-compose.production.yml logs -f" -ForegroundColor Yellow
Write-Host "To stop: $ComposeCmd -f docker-compose.production.yml down" -ForegroundColor Yellow
} else {
Write-Host "To view logs: $ComposeCmd logs -f" -ForegroundColor Yellow
Write-Host "To stop: $ComposeCmd down" -ForegroundColor Yellow
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Frontend Options:" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Development (with hot reload):" -ForegroundColor White
Write-Host " .\dev-start.ps1" -ForegroundColor Green
Write-Host " Frontend will be available at: http://localhost:4200" -ForegroundColor Gray
Write-Host ""
Write-Host "Or start full production stack:" -ForegroundColor White
Write-Host " .\quickstart.ps1 -FullStack" -ForegroundColor Green
Write-Host " Complete stack at: http://localhost:80" -ForegroundColor Gray
}
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Rebuild Options:" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Force rebuild backend: .\quickstart.ps1 -Rebuild" -ForegroundColor Yellow
Write-Host "Force rebuild full stack: .\quickstart.ps1 -Rebuild -FullStack" -ForegroundColor Yellow
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Testing the API..." -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host ""
# Wait a bit more for API to be fully ready
Start-Sleep -Seconds 5
# Test health endpoint
try {
$response = Invoke-RestMethod -Uri "http://localhost:8000/health" -Method Get -TimeoutSec 10
if ($response.status -eq "healthy") {
Write-Host "[OK] API is healthy!" -ForegroundColor Green
Write-Host ""
if (-not $FullStack) {
Write-Host "Ready for development!" -ForegroundColor Green
Write-Host "Run '.\dev-start.ps1' to start the frontend with hot reload" -ForegroundColor Yellow
Write-Host ""
}
Write-Host "Example: Upload a test file" -ForegroundColor White
Write-Host "----------------------------" -ForegroundColor Gray
Write-Host 'echo "test,data" > test.csv' -ForegroundColor Green
Write-Host 'curl -X POST "http://localhost:8000/api/v1/artifacts/upload"' -ForegroundColor Green
Write-Host ' -F "file=@test.csv"' -ForegroundColor Green
Write-Host ' -F "test_name=sample_test"' -ForegroundColor Green
Write-Host ' -F "test_suite=demo"' -ForegroundColor Green
Write-Host ' -F "test_result=pass"' -ForegroundColor Green
Write-Host ""
} else {
Write-Host "[WARNING] API returned unexpected status. Please check http://localhost:8000/health" -ForegroundColor Yellow
}
}
catch {
Write-Host "[WARNING] API is not responding yet. Please wait a moment and check http://localhost:8000/health" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Setup complete!" -ForegroundColor Green
Write-Host "=========================================" -ForegroundColor Cyan

189
scripts/quickstart.sh Normal file
View File

@@ -0,0 +1,189 @@
#!/bin/bash
set -e
echo "========================================="
echo "Test Artifact Data Lake - Quick Start"
echo "========================================="
echo ""
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "Error: Docker is not installed. Please install Docker first."
exit 1
fi
# Check if Docker Compose is installed
COMPOSE_CMD=""
if command -v docker-compose &> /dev/null; then
COMPOSE_CMD="docker-compose"
elif docker compose version &> /dev/null; then
COMPOSE_CMD="docker compose"
else
echo "Error: Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
# Parse command line arguments
REBUILD=false
FULL_STACK=false
DEV_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--rebuild)
REBUILD=true
shift
;;
--full-stack)
FULL_STACK=true
shift
;;
--dev)
DEV_MODE=true
shift
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --rebuild Force rebuild of all containers"
echo " --full-stack Start complete stack including frontend (production build)"
echo " --dev Start backend only, use separate dev server for frontend"
echo " --help Show this help message"
echo ""
echo "Default: Starts backend services only (recommended for development)"
echo ""
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Create .env file if it doesn't exist
if [ ! -f .env ]; then
echo "Creating .env file from .env.example..."
cp .env.example .env
echo "✓ .env file created"
else
echo "✓ .env file already exists"
fi
echo ""
# Stop existing containers if rebuild is requested
if [ "$REBUILD" = true ]; then
echo "🔄 Rebuilding containers..."
echo "Stopping existing containers..."
$COMPOSE_CMD down
if [ "$FULL_STACK" = true ]; then
echo "Removing existing images for full rebuild..."
$COMPOSE_CMD -f docker-compose.production.yml down --rmi local 2>/dev/null || true
echo "Building and starting full stack..."
$COMPOSE_CMD -f docker-compose.production.yml up -d --build
else
echo "Removing existing backend images for rebuild..."
$COMPOSE_CMD down --rmi local 2>/dev/null || true
echo "Building and starting backend services..."
$COMPOSE_CMD up -d --build
fi
else
# Regular startup
if [ "$FULL_STACK" = true ]; then
echo "Starting full production stack (backend + frontend)..."
$COMPOSE_CMD -f docker-compose.production.yml up -d
else
echo "Starting backend services..."
$COMPOSE_CMD up -d
fi
fi
echo ""
echo "Waiting for services to be ready..."
sleep 15
echo ""
echo "========================================="
if [ "$FULL_STACK" = true ]; then
echo "Complete Stack is running! 🚀"
else
echo "Backend Services are running!"
fi
echo "========================================="
echo ""
echo "API: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"
echo "MinIO Console: http://localhost:9001"
echo " Username: minioadmin"
echo " Password: minioadmin"
echo ""
if [ "$FULL_STACK" = true ]; then
echo "Frontend: http://localhost:80"
echo " (Production build with Nginx)"
echo ""
echo "To view logs: $COMPOSE_CMD -f docker-compose.production.yml logs -f"
echo "To stop: $COMPOSE_CMD -f docker-compose.production.yml down"
else
echo "To view logs: $COMPOSE_CMD logs -f"
echo "To stop: $COMPOSE_CMD down"
echo ""
echo "========================================="
echo "Frontend Options:"
echo "========================================="
echo ""
echo "Development (with hot reload):"
echo " ./dev-start.sh"
echo " Frontend will be available at: http://localhost:4200"
echo ""
echo "Or start full production stack:"
echo " ./quickstart.sh --full-stack"
echo " Complete stack at: http://localhost:80"
fi
echo ""
echo "========================================="
echo "Rebuild Options:"
echo "========================================="
echo ""
echo "Force rebuild backend: ./quickstart.sh --rebuild"
echo "Force rebuild full stack: ./quickstart.sh --rebuild --full-stack"
echo ""
echo "========================================="
echo "Testing the API..."
echo "========================================="
echo ""
# Wait a bit more for API to be fully ready
sleep 5
# Test health endpoint
if curl -s http://localhost:8000/health | grep -q "healthy"; then
echo "✓ API is healthy!"
echo ""
if [ "$FULL_STACK" = false ]; then
echo "🎯 Ready for development!"
echo "Run './dev-start.sh' to start the frontend with hot reload"
echo ""
fi
echo "Example: Upload a test file"
echo "----------------------------"
echo 'echo "test,data" > test.csv'
echo 'curl -X POST "http://localhost:8000/api/v1/artifacts/upload" \'
echo ' -F "file=@test.csv" \'
echo ' -F "test_name=sample_test" \'
echo ' -F "test_suite=demo" \'
echo ' -F "test_result=pass"'
echo ""
else
echo "⚠ API is not responding yet. Please wait a moment and check http://localhost:8000/health"
fi
echo "========================================="
echo "Setup complete! 🚀"
echo "========================================="

View File

@@ -1,208 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Artifact Data Lake</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>🗄️ Test Artifact Data Lake</h1>
<div class="header-info">
<span id="deployment-mode" class="badge"></span>
<span id="storage-backend" class="badge"></span>
</div>
</header>
<nav class="tabs">
<button class="tab-button active" onclick="showTab('artifacts')">📋 Artifacts</button>
<button class="tab-button" onclick="showTab('upload')">⬆️ Upload</button>
<button class="tab-button" onclick="showTab('query')">🔍 Query</button>
</nav>
<!-- Artifacts Tab -->
<div id="artifacts-tab" class="tab-content active">
<div class="toolbar">
<button onclick="loadArtifacts()" class="btn btn-primary">🔄 Refresh</button>
<button onclick="generateSeedData()" class="btn btn-secondary">🌱 Generate Seed Data</button>
<span id="artifact-count" class="count-badge"></span>
</div>
<div class="table-container">
<table id="artifacts-table">
<thead>
<tr>
<th>ID</th>
<th>Filename</th>
<th>Type</th>
<th>Size</th>
<th>Test Name</th>
<th>Suite</th>
<th>Result</th>
<th>Tags</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="artifacts-tbody">
<tr>
<td colspan="10" class="loading">Loading artifacts...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button onclick="previousPage()" id="prev-btn" class="btn">← Previous</button>
<span id="page-info">Page 1</span>
<button onclick="nextPage()" id="next-btn" class="btn">Next →</button>
</div>
</div>
<!-- Upload Tab -->
<div id="upload-tab" class="tab-content">
<div class="upload-section">
<h2>Upload Artifact</h2>
<form id="upload-form" onsubmit="uploadArtifact(event)">
<div class="form-group">
<label for="file">File *</label>
<input type="file" id="file" name="file" required>
<small>Supported: CSV, JSON, binary files, PCAP</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="test-name">Test Name</label>
<input type="text" id="test-name" name="test_name" placeholder="e.g., login_test">
</div>
<div class="form-group">
<label for="test-suite">Test Suite</label>
<input type="text" id="test-suite" name="test_suite" placeholder="e.g., integration">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="test-result">Test Result</label>
<select id="test-result" name="test_result">
<option value="">-- Select --</option>
<option value="pass">Pass</option>
<option value="fail">Fail</option>
<option value="skip">Skip</option>
<option value="error">Error</option>
</select>
</div>
<div class="form-group">
<label for="version">Version</label>
<input type="text" id="version" name="version" placeholder="e.g., v1.0.0">
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" placeholder="e.g., regression, smoke, critical">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="3" placeholder="Describe this artifact..."></textarea>
</div>
<div class="form-group">
<label for="test-config">Test Config (JSON)</label>
<textarea id="test-config" name="test_config" rows="4" placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
</div>
<div class="form-group">
<label for="custom-metadata">Custom Metadata (JSON)</label>
<textarea id="custom-metadata" name="custom_metadata" rows="4" placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
</div>
<button type="submit" class="btn btn-primary btn-large">📤 Upload Artifact</button>
</form>
<div id="upload-status"></div>
</div>
</div>
<!-- Query Tab -->
<div id="query-tab" class="tab-content">
<div class="query-section">
<h2>Query Artifacts</h2>
<form id="query-form" onsubmit="queryArtifacts(event)">
<div class="form-row">
<div class="form-group">
<label for="q-filename">Filename</label>
<input type="text" id="q-filename" placeholder="Search filename...">
</div>
<div class="form-group">
<label for="q-type">File Type</label>
<select id="q-type">
<option value="">All</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="binary">Binary</option>
<option value="pcap">PCAP</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-test-name">Test Name</label>
<input type="text" id="q-test-name" placeholder="Search test name...">
</div>
<div class="form-group">
<label for="q-suite">Test Suite</label>
<input type="text" id="q-suite" placeholder="e.g., integration">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-result">Test Result</label>
<select id="q-result">
<option value="">All</option>
<option value="pass">Pass</option>
<option value="fail">Fail</option>
<option value="skip">Skip</option>
<option value="error">Error</option>
</select>
</div>
<div class="form-group">
<label for="q-tags">Tags (comma-separated)</label>
<input type="text" id="q-tags" placeholder="e.g., regression, smoke">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-start-date">Start Date</label>
<input type="datetime-local" id="q-start-date">
</div>
<div class="form-group">
<label for="q-end-date">End Date</label>
<input type="datetime-local" id="q-end-date">
</div>
</div>
<button type="submit" class="btn btn-primary btn-large">🔍 Search</button>
<button type="button" onclick="clearQuery()" class="btn btn-secondary">Clear</button>
</form>
</div>
</div>
<!-- Artifact Detail Modal -->
<div id="detail-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeDetailModal()">&times;</span>
<h2>Artifact Details</h2>
<div id="detail-content"></div>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -1,462 +0,0 @@
// API Base URL
const API_BASE = '/api/v1';
// Pagination
let currentPage = 1;
let pageSize = 25;
let totalArtifacts = 0;
// Load API info on page load
window.addEventListener('DOMContentLoaded', () => {
loadApiInfo();
loadArtifacts();
});
// Load API information
async function loadApiInfo() {
try {
const response = await fetch('/api');
const data = await response.json();
document.getElementById('deployment-mode').textContent = `Mode: ${data.deployment_mode}`;
document.getElementById('storage-backend').textContent = `Storage: ${data.storage_backend}`;
} catch (error) {
console.error('Error loading API info:', error);
}
}
// Load artifacts
async function loadArtifacts(limit = pageSize, offset = 0) {
try {
const response = await fetch(`${API_BASE}/artifacts/?limit=${limit}&offset=${offset}`);
const artifacts = await response.json();
displayArtifacts(artifacts);
updatePagination(artifacts.length);
} catch (error) {
console.error('Error loading artifacts:', error);
document.getElementById('artifacts-tbody').innerHTML = `
<tr><td colspan="10" class="loading" style="color: #ef4444;">
Error loading artifacts: ${error.message}
</td></tr>
`;
}
}
// Display artifacts in table
function displayArtifacts(artifacts) {
const tbody = document.getElementById('artifacts-tbody');
if (artifacts.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="loading">No artifacts found. Upload some files to get started!</td></tr>';
document.getElementById('artifact-count').textContent = '0 artifacts';
return;
}
tbody.innerHTML = artifacts.map(artifact => `
<tr>
<td><strong>${artifact.id}</strong></td>
<td>
<a href="#" onclick="showDetail(${artifact.id}); return false;" style="color: #667eea; text-decoration: none;">
${escapeHtml(artifact.filename)}
</a>
</td>
<td><span class="file-type-badge">${artifact.file_type}</span></td>
<td>${formatBytes(artifact.file_size)}</td>
<td>${artifact.test_name || '-'}</td>
<td>${artifact.test_suite || '-'}</td>
<td>${formatResult(artifact.test_result)}</td>
<td>${formatTags(artifact.tags)}</td>
<td><small>${formatDate(artifact.created_at)}</small></td>
<td>
<div class="action-buttons">
<button class="icon-btn" onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" title="Download">
💾
</button>
<button class="icon-btn" onclick="deleteArtifact(${artifact.id})" title="Delete">
🗑️
</button>
</div>
</td>
</tr>
`).join('');
document.getElementById('artifact-count').textContent = `${artifacts.length} artifacts`;
}
// Format result badge
function formatResult(result) {
if (!result) return '-';
const className = `result-badge result-${result}`;
return `<span class="${className}">${result}</span>`;
}
// Format tags
function formatTags(tags) {
if (!tags || tags.length === 0) return '-';
return tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join(' ');
}
// Format bytes
function formatBytes(bytes) {
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];
}
// Format date
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString();
}
// Escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Show artifact detail
async function showDetail(id) {
try {
const response = await fetch(`${API_BASE}/artifacts/${id}`);
const artifact = await response.json();
const detailContent = document.getElementById('detail-content');
detailContent.innerHTML = `
<div class="detail-row">
<div class="detail-label">ID</div>
<div class="detail-value">${artifact.id}</div>
</div>
<div class="detail-row">
<div class="detail-label">Filename</div>
<div class="detail-value">${escapeHtml(artifact.filename)}</div>
</div>
<div class="detail-row">
<div class="detail-label">File Type</div>
<div class="detail-value"><span class="file-type-badge">${artifact.file_type}</span></div>
</div>
<div class="detail-row">
<div class="detail-label">Size</div>
<div class="detail-value">${formatBytes(artifact.file_size)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Storage Path</div>
<div class="detail-value"><code>${artifact.storage_path}</code></div>
</div>
<div class="detail-row">
<div class="detail-label">Test Name</div>
<div class="detail-value">${artifact.test_name || '-'}</div>
</div>
<div class="detail-row">
<div class="detail-label">Test Suite</div>
<div class="detail-value">${artifact.test_suite || '-'}</div>
</div>
<div class="detail-row">
<div class="detail-label">Test Result</div>
<div class="detail-value">${formatResult(artifact.test_result)}</div>
</div>
${artifact.test_config ? `
<div class="detail-row">
<div class="detail-label">Test Config</div>
<div class="detail-value"><pre>${JSON.stringify(artifact.test_config, null, 2)}</pre></div>
</div>
` : ''}
${artifact.custom_metadata ? `
<div class="detail-row">
<div class="detail-label">Custom Metadata</div>
<div class="detail-value"><pre>${JSON.stringify(artifact.custom_metadata, null, 2)}</pre></div>
</div>
` : ''}
${artifact.description ? `
<div class="detail-row">
<div class="detail-label">Description</div>
<div class="detail-value">${escapeHtml(artifact.description)}</div>
</div>
` : ''}
${artifact.tags && artifact.tags.length > 0 ? `
<div class="detail-row">
<div class="detail-label">Tags</div>
<div class="detail-value">${formatTags(artifact.tags)}</div>
</div>
` : ''}
<div class="detail-row">
<div class="detail-label">Version</div>
<div class="detail-value">${artifact.version || '-'}</div>
</div>
<div class="detail-row">
<div class="detail-label">Created</div>
<div class="detail-value">${formatDate(artifact.created_at)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Updated</div>
<div class="detail-value">${formatDate(artifact.updated_at)}</div>
</div>
<div style="margin-top: 20px; display: flex; gap: 10px;">
<button onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" class="btn btn-primary">
💾 Download
</button>
<button onclick="deleteArtifact(${artifact.id}); closeDetailModal();" class="btn btn-danger">
🗑️ Delete
</button>
</div>
`;
document.getElementById('detail-modal').classList.add('active');
} catch (error) {
alert('Error loading artifact details: ' + error.message);
}
}
// Close detail modal
function closeDetailModal() {
document.getElementById('detail-modal').classList.remove('active');
}
// Download artifact
async function downloadArtifact(id, filename) {
try {
const response = await fetch(`${API_BASE}/artifacts/${id}/download`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
alert('Error downloading artifact: ' + error.message);
}
}
// Delete artifact
async function deleteArtifact(id) {
if (!confirm('Are you sure you want to delete this artifact? This cannot be undone.')) {
return;
}
try {
const response = await fetch(`${API_BASE}/artifacts/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadArtifacts((currentPage - 1) * pageSize, pageSize);
alert('Artifact deleted successfully');
} else {
throw new Error('Failed to delete artifact');
}
} catch (error) {
alert('Error deleting artifact: ' + error.message);
}
}
// Upload artifact
async function uploadArtifact(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData();
// Add file
const fileInput = document.getElementById('file');
formData.append('file', fileInput.files[0]);
// Add optional fields
const fields = ['test_name', 'test_suite', 'test_result', 'version', 'description'];
fields.forEach(field => {
const value = form.elements[field].value;
if (value) formData.append(field, value);
});
// Add tags (convert comma-separated to JSON array)
const tags = document.getElementById('tags').value;
if (tags) {
const tagsArray = tags.split(',').map(t => t.trim()).filter(t => t);
formData.append('tags', JSON.stringify(tagsArray));
}
// Add JSON fields
const testConfig = document.getElementById('test-config').value;
if (testConfig) {
try {
JSON.parse(testConfig); // Validate
formData.append('test_config', testConfig);
} catch (e) {
showUploadStatus('Invalid Test Config JSON', false);
return;
}
}
const customMetadata = document.getElementById('custom-metadata').value;
if (customMetadata) {
try {
JSON.parse(customMetadata); // Validate
formData.append('custom_metadata', customMetadata);
} catch (e) {
showUploadStatus('Invalid Custom Metadata JSON', false);
return;
}
}
try {
const response = await fetch(`${API_BASE}/artifacts/upload`, {
method: 'POST',
body: formData
});
if (response.ok) {
const artifact = await response.json();
showUploadStatus(`Successfully uploaded: ${artifact.filename}`, true);
form.reset();
loadArtifacts();
} else {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
} catch (error) {
showUploadStatus('Upload failed: ' + error.message, false);
}
}
// Show upload status
function showUploadStatus(message, success) {
const status = document.getElementById('upload-status');
status.textContent = message;
status.className = success ? 'success' : 'error';
setTimeout(() => {
status.style.display = 'none';
}, 5000);
}
// Query artifacts
async function queryArtifacts(event) {
event.preventDefault();
const query = {};
const filename = document.getElementById('q-filename').value;
if (filename) query.filename = filename;
const fileType = document.getElementById('q-type').value;
if (fileType) query.file_type = fileType;
const testName = document.getElementById('q-test-name').value;
if (testName) query.test_name = testName;
const suite = document.getElementById('q-suite').value;
if (suite) query.test_suite = suite;
const result = document.getElementById('q-result').value;
if (result) query.test_result = result;
const tags = document.getElementById('q-tags').value;
if (tags) {
query.tags = tags.split(',').map(t => t.trim()).filter(t => t);
}
const startDate = document.getElementById('q-start-date').value;
if (startDate) query.start_date = new Date(startDate).toISOString();
const endDate = document.getElementById('q-end-date').value;
if (endDate) query.end_date = new Date(endDate).toISOString();
query.limit = 100;
query.offset = 0;
try {
const response = await fetch(`${API_BASE}/artifacts/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(query)
});
const artifacts = await response.json();
// Switch to artifacts tab and display results
showTab('artifacts');
displayArtifacts(artifacts);
} catch (error) {
alert('Query failed: ' + error.message);
}
}
// Clear query form
function clearQuery() {
document.getElementById('query-form').reset();
}
// Generate seed data
async function generateSeedData() {
const count = prompt('How many artifacts to generate? (1-100)', '10');
if (!count) return;
const num = parseInt(count);
if (isNaN(num) || num < 1 || num > 100) {
alert('Please enter a number between 1 and 100');
return;
}
try {
const response = await fetch(`/api/v1/seed/generate/${num}`, {
method: 'POST'
});
const result = await response.json();
if (response.ok) {
alert(result.message);
loadArtifacts();
} else {
throw new Error(result.detail || 'Generation failed');
}
} catch (error) {
alert('Error generating seed data: ' + error.message);
}
}
// Tab navigation
function showTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
// Show selected tab
document.getElementById(tabName + '-tab').classList.add('active');
event.target.classList.add('active');
}
// Pagination
function updatePagination(count) {
const pageInfo = document.getElementById('page-info');
pageInfo.textContent = `Page ${currentPage}`;
document.getElementById('prev-btn').disabled = currentPage === 1;
document.getElementById('next-btn').disabled = count < pageSize;
}
function previousPage() {
if (currentPage > 1) {
currentPage--;
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
}
}
function nextPage() {
currentPage++;
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
}

View File

@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
from app.database import SessionLocal
from app.models.artifact import Artifact
from app.models.tag import Tag
from app.storage import get_storage_backend
from app.config import settings
@@ -48,6 +49,22 @@ TAGS = [
"integration", "unit", "e2e", "api"
]
# Predefined tags with descriptions and colors
PREDEFINED_TAGS = [
{"name": "regression", "description": "Regression tests to verify existing functionality", "color": "#FF6B6B"},
{"name": "smoke", "description": "Quick smoke tests for basic functionality", "color": "#4ECDC4"},
{"name": "critical", "description": "Critical tests that must pass", "color": "#E74C3C"},
{"name": "high-priority", "description": "High priority tests", "color": "#F39C12"},
{"name": "automated", "description": "Automated test execution", "color": "#3498DB"},
{"name": "manual", "description": "Manual test execution required", "color": "#9B59B6"},
{"name": "performance", "description": "Performance and load tests", "color": "#1ABC9C"},
{"name": "security", "description": "Security and vulnerability tests", "color": "#E67E22"},
{"name": "integration", "description": "Integration tests between components", "color": "#2ECC71"},
{"name": "unit", "description": "Unit tests for individual components", "color": "#16A085"},
{"name": "e2e", "description": "End-to-end user journey tests", "color": "#8E44AD"},
{"name": "api", "description": "API endpoint tests", "color": "#2C3E50"},
]
def generate_csv_content() -> bytes:
"""Generate random CSV test data"""
@@ -183,6 +200,49 @@ def get_file_type(filename: str) -> str:
return type_mapping.get(extension, 'binary')
async def seed_predefined_tags() -> List[int]:
"""
Seed predefined tags into the database.
Returns:
List of created tag IDs
"""
db = SessionLocal()
tag_ids = []
try:
print("Seeding predefined tags...")
for tag_data in PREDEFINED_TAGS:
# Check if tag already exists
existing_tag = db.query(Tag).filter(Tag.name == tag_data["name"]).first()
if existing_tag:
print(f" Tag '{tag_data['name']}' already exists, skipping...")
tag_ids.append(existing_tag.id)
continue
tag = Tag(
name=tag_data["name"],
description=tag_data["description"],
color=tag_data["color"]
)
db.add(tag)
db.commit()
db.refresh(tag)
tag_ids.append(tag.id)
print(f" Created tag: {tag_data['name']}")
print(f"✓ Successfully seeded {len(tag_ids)} tags")
return tag_ids
except Exception as e:
db.rollback()
print(f"✗ Error seeding tags: {e}")
raise
finally:
db.close()
async def generate_seed_data(num_artifacts: int = 50) -> List[int]:
"""
Generate and upload seed data to the database and storage.
@@ -197,7 +257,11 @@ async def generate_seed_data(num_artifacts: int = 50) -> List[int]:
artifact_ids = []
try:
print(f"Generating {num_artifacts} seed artifacts...")
# First, seed tags
print("Step 1: Seeding tags...")
await seed_predefined_tags()
print(f"\nStep 2: Generating {num_artifacts} seed artifacts...")
print(f"Deployment mode: {settings.deployment_mode}")
print(f"Storage backend: {settings.storage_backend}")
@@ -269,42 +333,54 @@ async def generate_seed_data(num_artifacts: int = 50) -> List[int]:
async def clear_all_data():
"""
Clear all artifacts from database and storage.
Clear all artifacts and tags from database and storage.
WARNING: This will delete ALL data!
"""
db = SessionLocal()
storage = get_storage_backend()
try:
print("Clearing all artifacts...")
print("Clearing all data...")
# Get all artifacts
# Clear artifacts
artifacts = db.query(Artifact).all()
count = len(artifacts)
artifact_count = len(artifacts)
if count == 0:
if artifact_count > 0:
print(f"Found {artifact_count} artifacts to delete...")
# Delete from storage and database
for i, artifact in enumerate(artifacts):
try:
# Delete from storage
object_name = artifact.storage_path.split('/')[-1]
await storage.delete_file(object_name)
except Exception as e:
print(f" Warning: Could not delete {artifact.filename} from storage: {e}")
# Delete from database
db.delete(artifact)
if (i + 1) % 10 == 0:
print(f" Deleted {i + 1}/{artifact_count} artifacts...")
db.commit()
print(f"✓ Successfully deleted {artifact_count} artifacts")
else:
print("No artifacts to delete.")
return
print(f"Found {count} artifacts to delete...")
# Clear tags
tags = db.query(Tag).all()
tag_count = len(tags)
# Delete from storage and database
for i, artifact in enumerate(artifacts):
try:
# Delete from storage
object_name = artifact.storage_path.split('/')[-1]
await storage.delete_file(object_name)
except Exception as e:
print(f" Warning: Could not delete {artifact.filename} from storage: {e}")
# Delete from database
db.delete(artifact)
if (i + 1) % 10 == 0:
print(f" Deleted {i + 1}/{count} artifacts...")
db.commit()
print(f"✓ Successfully deleted {count} artifacts")
if tag_count > 0:
print(f"Found {tag_count} tags to delete...")
for tag in tags:
db.delete(tag)
db.commit()
print(f"✓ Successfully deleted {tag_count} tags")
else:
print("No tags to delete.")
except Exception as e:
db.rollback()