diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..418eb14 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,48 @@ +{ + "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:*)", + "Bash(git ls-tree:*)", + "mcp__ide__getDiagnostics", + "Read(//c/Users/Pratik/Desktop/code/**)", + "Bash(cat:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git merge:*)", + "Bash(git rm:*)", + "Bash(git checkout:*)", + "Bash(git push:*)", + "Bash(Start-Sleep -Seconds 10)", + "Bash(npm install:*)", + "Bash(npm config get:*)", + "Bash(test:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.dockerignore b/.dockerignore index 1fbf578..befb348 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ +# Python cache __pycache__ *.pyc *.pyo @@ -5,15 +6,50 @@ __pycache__ .Python env/ venv/ + +# Node modules - installed inside Docker to ensure correct platform binaries +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 diff --git a/.env.example b/.env.example index a132dd7..cf72db4 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,8 @@ MINIO_SECURE=false API_HOST=0.0.0.0 API_PORT=8000 MAX_UPLOAD_SIZE=524288000 + +# NPM Configuration (for frontend build) +# Leave blank or set to https://registry.npmjs.org/ for default npm registry +# Set to your custom npm proxy/registry URL if needed (e.g., http://your-nexus-server:8081/repository/npm-proxy/) +NPM_REGISTRY= diff --git a/.gitignore b/.gitignore index 64db696..75c32f4 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,15 @@ helm/charts/ tmp/ temp/ *.tmp +.claude/settings.local.json + +# Node/NPM +frontend/node_modules/ +frontend/.angular/ +frontend/dist/ +frontend/package-lock.json +frontend/package-lock*.json +frontend/.npmrc +frontend/.npmrc.* +# package-lock.json and .npmrc are machine-specific and depend on npm registry +# Each environment will generate its own based on local npm configuration diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 99cc754..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Deployment Guide - -This guide covers deploying the Test Artifact Data Lake in various environments. - -## Table of Contents -- [Local Development](#local-development) -- [Docker Compose](#docker-compose) -- [Kubernetes/Helm](#kuberneteshelm) -- [AWS Deployment](#aws-deployment) -- [Self-Hosted Deployment](#self-hosted-deployment) -- [GitLab CI/CD](#gitlab-cicd) - ---- - -## Local Development - -### Prerequisites -- Python 3.11+ -- PostgreSQL 15+ -- MinIO or AWS S3 access - -### Steps - -1. **Create virtual environment:** -```bash -python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate -``` - -2. **Install dependencies:** -```bash -pip install -r requirements.txt -``` - -3. **Set up PostgreSQL:** -```bash -createdb datalake -``` - -4. **Configure environment:** -```bash -cp .env.example .env -# Edit .env with your configuration -``` - -5. **Run the application:** -```bash -python -m uvicorn app.main:app --reload -``` - ---- - -## Docker Compose - -### Quick Start - -1. **Start all services:** -```bash -docker-compose up -d -``` - -2. **Check logs:** -```bash -docker-compose logs -f api -``` - -3. **Stop services:** -```bash -docker-compose down -``` - -### Services Included -- PostgreSQL (port 5432) -- MinIO (port 9000, console 9001) -- API (port 8000) - -### Customization - -Edit `docker-compose.yml` to: -- Change port mappings -- Adjust resource limits -- Add environment variables -- Configure volumes - ---- - -## Kubernetes/Helm - -### Prerequisites -- Kubernetes cluster (1.24+) -- Helm 3.x -- kubectl configured - -### Installation - -1. **Add dependencies (if using PostgreSQL/MinIO from Bitnami):** -```bash -helm repo add bitnami https://charts.bitnami.com/bitnami -helm repo update -``` - -2. **Install with default values:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace -``` - -3. **Custom installation:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set image.repository=your-registry/datalake \ - --set image.tag=1.0.0 \ - --set ingress.enabled=true \ - --set ingress.hosts[0].host=datalake.yourdomain.com -``` - -### Configuration Options - -**Image:** -```bash ---set image.repository=your-registry/datalake ---set image.tag=1.0.0 ---set image.pullPolicy=Always -``` - -**Resources:** -```bash ---set resources.requests.cpu=1000m ---set resources.requests.memory=1Gi ---set resources.limits.cpu=2000m ---set resources.limits.memory=2Gi -``` - -**Autoscaling:** -```bash ---set autoscaling.enabled=true ---set autoscaling.minReplicas=3 ---set autoscaling.maxReplicas=10 ---set autoscaling.targetCPUUtilizationPercentage=80 -``` - -**Ingress:** -```bash ---set ingress.enabled=true ---set ingress.className=nginx ---set ingress.hosts[0].host=datalake.example.com ---set ingress.hosts[0].paths[0].path=/ ---set ingress.hosts[0].paths[0].pathType=Prefix -``` - -### Upgrade - -```bash -helm upgrade datalake ./helm \ - --namespace datalake \ - --set image.tag=1.1.0 -``` - -### Uninstall - -```bash -helm uninstall datalake --namespace datalake -``` - ---- - -## AWS Deployment - -### Using AWS S3 Storage - -1. **Create S3 bucket:** -```bash -aws s3 mb s3://your-test-artifacts-bucket -``` - -2. **Create IAM user with S3 access:** -```bash -aws iam create-user --user-name datalake-service -aws iam attach-user-policy --user-name datalake-service \ - --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess -``` - -3. **Generate access keys:** -```bash -aws iam create-access-key --user-name datalake-service -``` - -4. **Deploy with Helm:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set config.storageBackend=s3 \ - --set aws.enabled=true \ - --set aws.accessKeyId=YOUR_ACCESS_KEY \ - --set aws.secretAccessKey=YOUR_SECRET_KEY \ - --set aws.region=us-east-1 \ - --set aws.bucketName=your-test-artifacts-bucket \ - --set minio.enabled=false -``` - -### Using EKS - -1. **Create EKS cluster:** -```bash -eksctl create cluster \ - --name datalake-cluster \ - --region us-east-1 \ - --nodegroup-name standard-workers \ - --node-type t3.medium \ - --nodes 3 -``` - -2. **Configure kubectl:** -```bash -aws eks update-kubeconfig --name datalake-cluster --region us-east-1 -``` - -3. **Deploy application:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set config.storageBackend=s3 -``` - -### Using RDS for PostgreSQL - -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set postgresql.enabled=false \ - --set config.databaseUrl="postgresql://user:pass@your-rds-endpoint:5432/datalake" -``` - ---- - -## Self-Hosted Deployment - -### Using MinIO - -1. **Deploy MinIO:** -```bash -helm install minio bitnami/minio \ - --namespace datalake \ - --create-namespace \ - --set auth.rootUser=admin \ - --set auth.rootPassword=adminpassword \ - --set persistence.size=100Gi -``` - -2. **Deploy application:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --set config.storageBackend=minio \ - --set minio.enabled=false \ - --set minio.endpoint=minio:9000 \ - --set minio.accessKey=admin \ - --set minio.secretKey=adminpassword -``` - -### On-Premise Kubernetes - -1. **Prepare persistent volumes:** -```yaml -apiVersion: v1 -kind: PersistentVolume -metadata: - name: datalake-postgres-pv -spec: - capacity: - storage: 20Gi - accessModes: - - ReadWriteOnce - hostPath: - path: /data/postgres -``` - -2. **Deploy with local storage:** -```bash -helm install datalake ./helm \ - --namespace datalake \ - --create-namespace \ - --set postgresql.persistence.storageClass=local-storage \ - --set minio.persistence.storageClass=local-storage -``` - ---- - -## GitLab CI/CD - -### Setup - -1. **Configure GitLab variables:** - -Go to Settings → CI/CD → Variables and add: - -| Variable | Description | Protected | Masked | -|----------|-------------|-----------|---------| -| `CI_REGISTRY_USER` | Docker registry username | No | No | -| `CI_REGISTRY_PASSWORD` | Docker registry password | No | Yes | -| `KUBE_CONFIG_DEV` | Base64 kubeconfig for dev | No | Yes | -| `KUBE_CONFIG_STAGING` | Base64 kubeconfig for staging | Yes | Yes | -| `KUBE_CONFIG_PROD` | Base64 kubeconfig for prod | Yes | Yes | - -2. **Encode kubeconfig:** -```bash -cat ~/.kube/config | base64 -w 0 -``` - -### Pipeline Stages - -1. **Test**: Runs on all branches and MRs -2. **Build**: Builds Docker image on main/develop/tags -3. **Deploy**: Manual deployment to dev/staging/prod - -### Deployment Flow - -**Development:** -```bash -git push origin develop -# Manually trigger deploy:dev job in GitLab -``` - -**Staging:** -```bash -git push origin main -# Manually trigger deploy:staging job in GitLab -``` - -**Production:** -```bash -git tag v1.0.0 -git push origin v1.0.0 -# Manually trigger deploy:prod job in GitLab -``` - -### Customizing Pipeline - -Edit `.gitlab-ci.yml` to: -- Add more test stages -- Change deployment namespaces -- Adjust Helm values per environment -- Add security scanning -- Configure rollback procedures - ---- - -## Monitoring - -### Health Checks - -```bash -# Kubernetes -kubectl get pods -n datalake -kubectl logs -f -n datalake deployment/datalake - -# Direct -curl http://localhost:8000/health -``` - -### Metrics - -Add Prometheus monitoring: -```bash -helm install datalake ./helm \ - --set metrics.enabled=true \ - --set serviceMonitor.enabled=true -``` - ---- - -## Backup and Recovery - -### Database Backup - -```bash -# PostgreSQL -kubectl exec -n datalake deployment/datalake-postgresql -- \ - pg_dump -U user datalake > backup.sql - -# Restore -kubectl exec -i -n datalake deployment/datalake-postgresql -- \ - psql -U user datalake < backup.sql -``` - -### Storage Backup - -**S3:** -```bash -aws s3 sync s3://your-bucket s3://backup-bucket -``` - -**MinIO:** -```bash -mc mirror minio/test-artifacts backup/test-artifacts -``` - ---- - -## Troubleshooting - -### Pod Not Starting -```bash -kubectl describe pod -n datalake -kubectl logs -n datalake -``` - -### Database Connection Issues -```bash -kubectl exec -it -n datalake deployment/datalake -- \ - psql $DATABASE_URL -``` - -### Storage Issues -```bash -# Check MinIO -kubectl port-forward -n datalake svc/minio 9000:9000 -# Access http://localhost:9000 -``` - ---- - -## Security Considerations - -1. **Use secrets management:** - - Kubernetes Secrets - - AWS Secrets Manager - - HashiCorp Vault - -2. **Enable TLS:** - - Configure ingress with TLS certificates - - Use cert-manager for automatic certificates - -3. **Network policies:** - - Restrict pod-to-pod communication - - Limit external access - -4. **RBAC:** - - Configure Kubernetes RBAC - - Limit service account permissions - ---- - -## Performance Tuning - -### Database -- Increase connection pool size -- Add database indexes -- Configure autovacuum - -### API -- Increase replica count -- Configure horizontal pod autoscaling -- Adjust resource requests/limits - -### Storage -- Use CDN for frequently accessed files -- Configure S3 Transfer Acceleration -- Optimize MinIO deployment diff --git a/Dockerfile b/Dockerfile index 66de827..aa3cb77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,42 @@ +# Multi-stage build: First stage builds Angular frontend +FROM node:18-alpine as frontend-builder + +WORKDIR /frontend + +# Copy package files first +COPY frontend/package*.json ./ + +# Copy .npmrc if it exists (allows using custom npm registry) +# The quickstart scripts will create this from user's .npmrc +# Using a conditional copy approach +RUN --mount=type=bind,source=frontend,target=/tmp/frontend \ + if [ -f /tmp/frontend/.npmrc ]; then cp /tmp/frontend/.npmrc ./; fi + +# Install dependencies inside Docker (ensures correct platform binaries) +RUN npm install + +# Copy frontend source +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 + +# Second stage: Python backend with Angular static files FROM python:3.11-alpine WORKDIR /app # Install system dependencies for Alpine -# Alpine uses apk instead of apt-get and is lighter/faster RUN apk add --no-cache \ gcc \ musl-dev \ postgresql-dev \ postgresql-client \ - linux-headers + linux-headers \ + curl # Copy requirements and install Python dependencies COPY requirements.txt . @@ -20,7 +47,9 @@ COPY app/ ./app/ COPY utils/ ./utils/ COPY alembic/ ./alembic/ COPY alembic.ini . -COPY static/ ./static/ + +# Copy Angular build from frontend-builder stage +COPY --from=frontend-builder /frontend/dist/frontend/browser ./static # Create non-root user (Alpine uses adduser instead of useradd) RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app @@ -31,7 +60,7 @@ EXPOSE 8000 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import requests; requests.get('http://localhost:8000/health')" + CMD curl -f http://localhost:8000/health # Run the application CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 70a217c..42f6474 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,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 @@ -274,6 +269,23 @@ Store compiled binaries, test data files, or any binary artifacts with full meta ## Development +### NPM Registry Configuration + +The frontend supports working with multiple npm registries (public npm vs corporate Artifactory). See [frontend/README-REGISTRY.md](frontend/README-REGISTRY.md) for detailed instructions. + +**Quick switch:** +```bash +cd frontend + +# Use public npm (default) +npm run registry:public +npm ci --force + +# Use Artifactory +npm run registry:artifactory +npm ci --force +``` + ### Running Tests ```bash pytest tests/ -v @@ -308,6 +320,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] diff --git a/app/api/tags.py b/app/api/tags.py new file mode 100644 index 0000000..bb45623 --- /dev/null +++ b/app/api/tags.py @@ -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 diff --git a/app/database.py b/app/database.py index b53ae14..2e2b077 100644 --- a/app/database.py +++ b/app/database.py @@ -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(): diff --git a/app/main.py b/app/main.py index b1e0034..1e818b0 100644 --- a/app/main.py +++ b/app/main.py @@ -4,10 +4,12 @@ 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 import os +from pathlib import Path # Configure logging logging.basicConfig( @@ -19,8 +21,8 @@ logger = logging.getLogger(__name__) # Create FastAPI app app = FastAPI( - title="Obsidian", - description="Enterprise Test Artifact Storage - API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures", + title="Test Artifact Data Lake", + description="API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures", version="1.0.0", docs_url="/docs", redoc_url="/redoc" @@ -38,11 +40,10 @@ 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") +# Static files configuration - will be set up after routes +static_dir = Path("/app/static") @app.on_event("startup") @@ -59,7 +60,7 @@ async def startup_event(): async def api_root(): """API root endpoint""" return { - "message": "Obsidian - Enterprise Test Artifact Storage", + "message": "Test Artifact Data Lake API", "version": "1.0.0", "docs": "/docs", "deployment_mode": settings.deployment_mode, @@ -68,17 +69,17 @@ 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) +async def serve_angular_app(): + """Serve Angular app index.html""" + static_dir = Path("/app/static") + if static_dir.exists(): + return FileResponse(static_dir / "index.html") else: + # Fallback if static files not found return { - "message": "Obsidian - Enterprise Test Artifact Storage", + "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 } @@ -90,6 +91,31 @@ async def health_check(): return {"status": "healthy"} +# Catch-all route for Angular client-side routing +# This must be last to not interfere with API routes +@app.get("/{full_path:path}") +async def catch_all(full_path: str): + """Serve Angular app for all non-API routes (SPA routing)""" + if static_dir.exists(): + # Check if the requested path is a file in the static directory + file_path = static_dir / full_path + if file_path.is_file() and file_path.exists(): + # Determine media type based on file extension + media_type = None + if file_path.suffix == ".js": + media_type = "application/javascript" + elif file_path.suffix == ".css": + media_type = "text/css" + elif file_path.suffix in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico"]: + media_type = f"image/{file_path.suffix[1:]}" + return FileResponse(file_path, media_type=media_type) + # Otherwise, serve index.html for client-side routing + index_path = static_dir / "index.html" + if index_path.exists(): + return FileResponse(index_path, media_type="text/html") + return {"error": "Static files not found"} + + if __name__ == "__main__": import uvicorn uvicorn.run( diff --git a/app/models/artifact.py b/app/models/artifact.py index 63d6f89..70127bd 100644 --- a/app/models/artifact.py +++ b/app/models/artifact.py @@ -20,9 +20,7 @@ class Artifact(Base): test_suite = Column(String(500), index=True) test_config = Column(JSON) test_result = Column(String(50), index=True) # pass, fail, skip, error - - # SIM source grouping - allows multiple artifacts per source - sim_source_id = Column(String(100), index=True) # Groups artifacts from same SIM source + sim_source_id = Column(String(500), index=True) # SIM source identifier for grouping # Additional metadata custom_metadata = Column(JSON) diff --git a/app/models/tag.py b/app/models/tag.py new file mode 100644 index 0000000..3765daf --- /dev/null +++ b/app/models/tag.py @@ -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"" diff --git a/app/schemas/artifact.py b/app/schemas/artifact.py index 1cc2d13..9ab1338 100644 --- a/app/schemas/artifact.py +++ b/app/schemas/artifact.py @@ -8,7 +8,7 @@ class ArtifactCreate(BaseModel): test_suite: Optional[str] = None test_config: Optional[Dict[str, Any]] = None test_result: Optional[str] = None - sim_source_id: Optional[str] = None # Groups artifacts from same SIM source + sim_source_id: Optional[str] = None custom_metadata: Optional[Dict[str, Any]] = None description: Optional[str] = None tags: Optional[List[str]] = None diff --git a/app/schemas/tag.py b/app/schemas/tag.py new file mode 100644 index 0000000..8d894c8 --- /dev/null +++ b/app/schemas/tag.py @@ -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 diff --git a/dev-start.ps1 b/dev-start.ps1 new file mode 100644 index 0000000..a5dd219 --- /dev/null +++ b/dev-start.ps1 @@ -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 \ No newline at end of file diff --git a/dev-start.sh b/dev-start.sh new file mode 100644 index 0000000..dd86542 --- /dev/null +++ b/dev-start.sh @@ -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 \ No newline at end of file diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..b8ba04b --- /dev/null +++ b/docker-compose.production.yml @@ -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: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1faff35..b7587e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: timeout: 5s retries: 5 - api: + app: build: . ports: - "8000:8000" @@ -52,10 +52,11 @@ services: minio: condition: service_healthy healthcheck: - test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"] - interval: 30s - timeout: 10s - retries: 3 + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s volumes: postgres_data: diff --git a/API.md b/docs/API.md similarity index 100% rename from API.md rename to docs/API.md diff --git a/ARCHITECTURE.md b/docs/ARCHITECTURE.md similarity index 100% rename from ARCHITECTURE.md rename to docs/ARCHITECTURE.md diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..7d92151 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,113 @@ +# Deployment Options + +This project supports two deployment strategies for the Angular frontend, depending on your environment's network access. + +## Option 1: Standard Build (Internet Access Required) + +Use the standard `Dockerfile.frontend` which builds the Angular app inside Docker. + +**Requirements:** +- Internet access to npm registry +- Docker build environment + +**Usage:** +```bash +./quickstart.sh +# or +docker-compose up -d --build +``` + +This uses `Dockerfile.frontend` which: +1. Installs npm dependencies in Docker +2. Builds Angular app in Docker +3. Serves with nginx + +--- + +## Option 2: Pre-built Deployment (Air-Gapped/Restricted Environments) + +Use `Dockerfile.frontend.prebuilt` for environments with restricted npm access or when esbuild platform binaries cannot be downloaded. + +**Requirements:** +- Node.js 24+ installed locally +- npm installed locally +- No internet required during Docker build + +**Usage:** + +### Step 1: Build Angular app locally +```bash +cd frontend +npm install # Only needed once or when dependencies change +npm run build:prod +cd .. +``` + +### Step 2: Update docker-compose.yml +Edit `docker-compose.yml` and change the frontend dockerfile: + +```yaml + frontend: + build: + context: . + dockerfile: Dockerfile.frontend.prebuilt # <-- Change this line + ports: + - "4200:80" + depends_on: + - api +``` + +### Step 3: Build and deploy +```bash +docker-compose up -d --build +``` + +This uses `Dockerfile.frontend.prebuilt` which: +1. Copies pre-built Angular files from `frontend/dist/` +2. Serves with nginx +3. No npm/node required in Docker + +--- + +## Troubleshooting + +### esbuild Platform Binary Issues + +If you see errors like: +``` +Could not resolve "@esbuild/darwin-arm64" +``` + +**Solution 1:** Use Option 2 (Pre-built) above + +**Solution 2:** Add platform binaries to package.json (already included): +```json +"optionalDependencies": { + "@esbuild/darwin-arm64": "^0.25.4", + "@esbuild/darwin-x64": "^0.25.4", + "@esbuild/linux-arm64": "^0.25.4", + "@esbuild/linux-x64": "^0.25.4" +} +``` + +**Solution 3:** Use custom npm registry with cached esbuild binaries + +### Custom NPM Registry + +For both options, you can use a custom npm registry: + +```bash +# Set in .env file +NPM_REGISTRY=http://your-npm-proxy:8081/repository/npm-proxy/ + +# Or inline +NPM_REGISTRY=http://your-proxy ./quickstart.sh +``` + +--- + +## Recommendation + +- **Development/Cloud**: Use Option 1 (standard) +- **Air-gapped/Enterprise**: Use Option 2 (pre-built) +- **CI/CD**: Use Option 2 for faster, more reliable builds diff --git a/FEATURES.md b/docs/FEATURES.md similarity index 100% rename from FEATURES.md rename to docs/FEATURES.md diff --git a/FRONTEND_SETUP.md b/docs/FRONTEND_SETUP.md similarity index 100% rename from FRONTEND_SETUP.md rename to docs/FRONTEND_SETUP.md diff --git a/docs/FRONTEND_USAGE.md b/docs/FRONTEND_USAGE.md new file mode 100644 index 0000000..064edf2 --- /dev/null +++ b/docs/FRONTEND_USAGE.md @@ -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 \ No newline at end of file diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..8fd012c --- /dev/null +++ b/docs/QUICKSTART.md @@ -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! \ No newline at end of file diff --git a/SUMMARY.md b/docs/SUMMARY.md similarity index 100% rename from SUMMARY.md rename to docs/SUMMARY.md diff --git a/docs/todos.md b/docs/todos.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cc7b141 --- /dev/null +++ b/frontend/.gitignore @@ -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 diff --git a/frontend/README-REGISTRY.md b/frontend/README-REGISTRY.md new file mode 100644 index 0000000..8ade3cc --- /dev/null +++ b/frontend/README-REGISTRY.md @@ -0,0 +1,192 @@ +# NPM Registry Configuration + +This project supports working with two different npm registries: +1. **Public registry** - registry.npmjs.org (default) +2. **Artifactory** - Your corporate Artifactory npm registry + +## Quick Start + +### Switching Registries Locally + +**On Linux/Mac:** +```bash +cd frontend + +# Use public npm registry (default) +./switch-registry.sh public +npm ci --force + +# Use Artifactory registry +./switch-registry.sh artifactory +# Set auth token if required +export ARTIFACTORY_AUTH_TOKEN="your_token_here" +npm ci --force +``` + +**On Windows:** +```powershell +cd frontend + +# Use public npm registry (default) +.\switch-registry.ps1 public +npm ci --force + +# Use Artifactory registry +.\switch-registry.ps1 artifactory +# Set auth token if required +$env:ARTIFACTORY_AUTH_TOKEN = "your_token_here" +npm ci --force +``` + +### Building with Docker + +**Using public npm registry (default):** +```bash +docker compose build app +``` + +**Using Artifactory registry:** +```bash +# Without authentication +docker compose build app --build-arg NPM_REGISTRY=artifactory + +# With authentication +docker compose build app \ + --build-arg NPM_REGISTRY=artifactory \ + --build-arg ARTIFACTORY_AUTH_TOKEN="your_token_here" +``` + +**On Windows PowerShell:** +```powershell +# With authentication +docker compose build app ` + --build-arg NPM_REGISTRY=artifactory ` + --build-arg ARTIFACTORY_AUTH_TOKEN="your_token_here" +``` + +## Configuration Files + +- **`.npmrc.public`** - Configuration for public npm registry +- **`.npmrc.artifactory`** - Configuration for Artifactory registry (edit this with your Artifactory URL) +- **`.npmrc`** - Active configuration (generated by switch-registry scripts) + +## Setup Artifactory Configuration + +1. Edit `frontend/.npmrc.artifactory` and replace `YOUR_ARTIFACTORY_URL` with your actual Artifactory URL: + ``` + registry=https://artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/ + ``` + +2. If authentication is required, uncomment the auth lines and use one of these methods: + + **Method 1: Auth Token (Recommended)** + ``` + //artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:_auth=${ARTIFACTORY_AUTH_TOKEN} + //artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:always-auth=true + ``` + + Then set the environment variable: + ```bash + export ARTIFACTORY_AUTH_TOKEN="your_base64_encoded_token" + ``` + + **Method 2: Username/Password** + ``` + //artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:username=${ARTIFACTORY_USERNAME} + //artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:_password=${ARTIFACTORY_PASSWORD} + //artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:email=your-email@company.com + ``` + +## Handling package-lock.json + +The `package-lock.json` file will be different depending on which registry you use. Here are strategies to manage this: + +### Strategy 1: Separate Lockfiles (Recommended) +Keep two lockfiles and switch between them: + +```bash +# After switching to public and installing +npm ci --force +cp package-lock.json package-lock.public.json + +# After switching to artifactory and installing +npm ci --force +cp package-lock.json package-lock.artifactory.json + +# When switching registries in the future +cp package-lock.public.json package-lock.json # or +cp package-lock.artifactory.json package-lock.json +``` + +### Strategy 2: Regenerate Lockfile +Always regenerate the lockfile after switching: + +```bash +./switch-registry.sh artifactory +rm package-lock.json +npm install +``` + +### Strategy 3: Git Ignore Lockfile (Not Recommended for Production) +If you're frequently switching and don't need deterministic builds: + +Add to `.gitignore`: +``` +frontend/package-lock.json +``` + +**Warning:** This reduces build reproducibility. + +## Troubleshooting + +### Issue: "npm ci requires package-lock.json" +**Solution:** Delete `package-lock.json` and run `npm install` to generate a new one for your current registry. + +### Issue: "404 Not Found - GET https://registry.npmjs.org/..." +**Solution:** Your .npmrc is pointing to Artifactory but packages don't exist there. +```bash +./switch-registry.sh public +npm ci --force +``` + +### Issue: "401 Unauthorized" +**Solution:** Check your authentication configuration in `.npmrc.artifactory` and ensure environment variables are set correctly. + +### Issue: "ENOENT: no such file or directory, open '.npmrc.public'" +**Solution:** You're missing the registry config files. Make sure both `.npmrc.public` and `.npmrc.artifactory` exist in the frontend directory. + +## CI/CD Integration + +For CI/CD pipelines, use environment variables to select the registry: + +**GitHub Actions Example:** +```yaml +- name: Build with Artifactory + env: + NPM_REGISTRY: artifactory + ARTIFACTORY_AUTH_TOKEN: ${{ secrets.ARTIFACTORY_TOKEN }} + run: | + docker compose build app \ + --build-arg NPM_REGISTRY=artifactory \ + --build-arg ARTIFACTORY_AUTH_TOKEN="${ARTIFACTORY_AUTH_TOKEN}" +``` + +**GitLab CI Example:** +```yaml +build: + script: + - docker compose build app + --build-arg NPM_REGISTRY=artifactory + --build-arg ARTIFACTORY_AUTH_TOKEN="${ARTIFACTORY_AUTH_TOKEN}" + variables: + NPM_REGISTRY: artifactory + ARTIFACTORY_AUTH_TOKEN: ${CI_ARTIFACTORY_TOKEN} +``` + +## Best Practices + +1. **Never commit credentials** - Use environment variables for tokens/passwords +2. **Document your Artifactory URL** - Update `.npmrc.artifactory` with your team's URL +3. **Keep both config files** - Commit `.npmrc.public` and `.npmrc.artifactory` to git +4. **Use the scripts** - Always use `switch-registry.sh/ps1` instead of manually editing `.npmrc` +5. **Clean installs** - Use `npm ci --force` after switching to ensure a clean dependency tree diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..303a19f --- /dev/null +++ b/frontend/README.md @@ -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 \ No newline at end of file diff --git a/frontend/REGISTRY-EXAMPLES.md b/frontend/REGISTRY-EXAMPLES.md new file mode 100644 index 0000000..37708be --- /dev/null +++ b/frontend/REGISTRY-EXAMPLES.md @@ -0,0 +1,339 @@ +# NPM Registry - Usage Examples + +## Quick Reference + +### Use Public NPM (Default) +```bash +# Linux/Mac +./quickstart.sh + +# Windows +.\quickstart.ps1 +``` + +### Use Artifactory +```bash +# Linux/Mac +export ARTIFACTORY_AUTH_TOKEN="your_token_here" +./quickstart.sh -bsf + +# Windows +$env:ARTIFACTORY_AUTH_TOKEN = "your_token_here" +.\quickstart.ps1 -Bsf +``` + +### Rebuild with Artifactory +```bash +# Linux/Mac +export ARTIFACTORY_AUTH_TOKEN="your_token_here" +./quickstart.sh --rebuild -bsf + +# Windows +$env:ARTIFACTORY_AUTH_TOKEN = "your_token_here" +.\quickstart.ps1 -Rebuild -Bsf +``` + +## Local Development (Without Docker) + +### Switch Registry for Local Development + +**Linux/Mac:** +```bash +cd frontend + +# Switch to public npm +./switch-registry.sh public +npm ci --force +npm start + +# Switch to Artifactory +./switch-registry.sh artifactory +export ARTIFACTORY_AUTH_TOKEN="your_token" +npm ci --force +npm start +``` + +**Windows:** +```powershell +cd frontend + +# Switch to public npm +.\switch-registry.ps1 public +npm ci --force +npm start + +# Switch to Artifactory +.\switch-registry.ps1 artifactory +$env:ARTIFACTORY_AUTH_TOKEN = "your_token" +npm ci --force +npm start +``` + +**Using NPM Scripts (Cross-platform):** +```bash +cd frontend + +# Switch to public npm +npm run registry:public +npm ci --force +npm start + +# Switch to Artifactory +npm run registry:artifactory +npm ci --force +npm start +``` + +## Docker Build Examples + +### Build Specific Service with Registry + +**Public NPM:** +```bash +docker compose build app +``` + +**Artifactory:** +```bash +# Without auth +docker compose build app --build-arg NPM_REGISTRY=artifactory + +# With auth +docker compose build app \ + --build-arg NPM_REGISTRY=artifactory \ + --build-arg ARTIFACTORY_AUTH_TOKEN="your_token" +``` + +**Windows PowerShell:** +```powershell +docker compose build app ` + --build-arg NPM_REGISTRY=artifactory ` + --build-arg ARTIFACTORY_AUTH_TOKEN="your_token" +``` + +## Common Workflows + +### Corporate Network Development +When working from a corporate network that requires Artifactory: + +1. **First time setup:** + ```bash + # Edit .npmrc.artifactory with your Artifactory URL + nano frontend/.npmrc.artifactory + + # Set auth token (get from your Artifactory admin) + export ARTIFACTORY_AUTH_TOKEN="your_base64_token" + + # Start with Artifactory + ./quickstart.sh -bsf + ``` + +2. **Daily development:** + ```bash + export ARTIFACTORY_AUTH_TOKEN="your_token" + ./quickstart.sh -bsf + ``` + +### Home/Public Network Development +When working from home or a network with npm access: + +```bash +# Just run without -bsf flag +./quickstart.sh +``` + +### Switching Between Environments + +**Moving from Corporate to Home:** +```bash +# Stop existing containers +docker compose down + +# Rebuild with public npm +./quickstart.sh --rebuild +``` + +**Moving from Home to Corporate:** +```bash +# Stop existing containers +docker compose down + +# Rebuild with Artifactory +export ARTIFACTORY_AUTH_TOKEN="your_token" +./quickstart.sh --rebuild -bsf +``` + +## Handling Multiple package-lock.json Files + +### Save lockfiles for both registries: + +```bash +cd frontend + +# Generate public lockfile +./switch-registry.sh public +rm package-lock.json +npm install +cp package-lock.json package-lock.public.json + +# Generate artifactory lockfile +./switch-registry.sh artifactory +rm package-lock.json +npm install +cp package-lock.json package-lock.artifactory.json + +# Add to git +git add package-lock.public.json package-lock.artifactory.json +``` + +### Use the appropriate lockfile: + +```bash +# When using public npm +cp package-lock.public.json package-lock.json +npm ci + +# When using Artifactory +cp package-lock.artifactory.json package-lock.json +npm ci +``` + +## CI/CD Examples + +### GitHub Actions + +**.github/workflows/build.yml** +```yaml +name: Build + +on: [push, pull_request] + +jobs: + build-public: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build with public npm + run: | + docker compose build app + docker compose up -d + + build-artifactory: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build with Artifactory + env: + ARTIFACTORY_AUTH_TOKEN: ${{ secrets.ARTIFACTORY_TOKEN }} + run: | + ./quickstart.sh -bsf +``` + +### GitLab CI + +**.gitlab-ci.yml** +```yaml +variables: + NPM_REGISTRY: "public" + +build:public: + stage: build + script: + - docker compose build app + - docker compose up -d + only: + - main + +build:artifactory: + stage: build + variables: + NPM_REGISTRY: "artifactory" + script: + - export ARTIFACTORY_AUTH_TOKEN="${CI_ARTIFACTORY_TOKEN}" + - ./quickstart.sh -bsf + only: + - develop +``` + +### Jenkins Pipeline + +**Jenkinsfile** +```groovy +pipeline { + agent any + + environment { + ARTIFACTORY_AUTH_TOKEN = credentials('artifactory-npm-token') + } + + stages { + stage('Build with Artifactory') { + steps { + sh './quickstart.sh -bsf' + } + } + } +} +``` + +## Troubleshooting + +### Build fails with "Cannot find .npmrc.public" + +**Problem:** Registry config files are missing. + +**Solution:** +```bash +cd frontend +# Verify files exist +ls -la .npmrc.* + +# If missing, they should be committed to git +git status +``` + +### "ENOENT: no such file or directory, open '/frontend/dist/frontend/browser'" + +**Problem:** Frontend build failed due to registry issues. + +**Solution:** +```bash +# Check build logs +docker compose logs app | grep npm + +# Try rebuilding with verbose logging +docker compose build app --no-cache --progress=plain +``` + +### npm ci fails with 404 errors + +**Problem:** Wrong registry is configured. + +**Solution:** +```bash +cd frontend +cat .npmrc # Check which registry is active + +# If using wrong one, switch: +npm run registry:public # or registry:artifactory +npm ci --force +``` + +### Authentication fails with Artifactory + +**Problem:** Token is invalid or not set. + +**Solution:** +```bash +# Check token is set +echo $ARTIFACTORY_AUTH_TOKEN # Linux/Mac +echo $env:ARTIFACTORY_AUTH_TOKEN # Windows + +# Get new token from Artifactory UI: +# Artifactory -> User Profile -> Generate Token + +# Set the token +export ARTIFACTORY_AUTH_TOKEN="your_new_token" +``` diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..ea9cfa8 --- /dev/null +++ b/frontend/angular.json @@ -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": [] + } + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d3f1210 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,55 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "registry:public": "node -e \"require('fs').copyFileSync('.npmrc.public', '.npmrc'); console.log('✓ Switched to public npm registry');\"", + "registry:artifactory": "node -e \"require('fs').copyFileSync('.npmrc.artifactory', '.npmrc'); console.log('✓ Switched to Artifactory registry');\"" + }, + "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", + "@types/node": "22.10.5", + "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", + "vite": "6.3.6", + "rollup": "4.50.2", + "undici-types": "7.12.0" + }, + "resolutions": { + "vite": "6.3.6", + "rollup": "4.50.2", + "undici-types": "7.12.0" + }, + "overrides": { + "vite": "6.3.6", + "rollup": "4.50.2", + "undici-types": "7.12.0" + } +} \ No newline at end of file diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..951b7da --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,11 @@ +{ + "/api": { + "target": "http://localhost:8000", + "secure": false, + "changeOrigin": true, + "logLevel": "debug", + "pathRewrite": { + "^/api": "/api" + } + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html new file mode 100644 index 0000000..1da2cdb --- /dev/null +++ b/frontend/src/app/app.component.html @@ -0,0 +1,152 @@ +
+ + + diamond + ◆ Obsidian + +
+ + + settings + Mode: {{ apiInfo.deployment_mode }} + + + folder + Storage: {{ apiInfo.storage_backend }} + + +
+
+ + + + + + +
+
+ + + + {{ artifacts.length }} artifacts + + Search + + search + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ artifact.id }}Filename + description + {{ artifact.filename }} + Type + {{ artifact.file_type }} + Size{{ formatBytes(artifact.file_size) }}Test Name{{ artifact.test_name || '-' }}Result + + {{ artifact.test_result }} + + - + Actions +
+ + +
+
+
+
+
+ + + + +
+
+

+ cloud_upload + Upload Artifacts +

+
+ +
+
+
+ + + + +
+
+

+ search + Query Artifacts +

+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss new file mode 100644 index 0000000..5d9bba9 --- /dev/null +++ b/frontend/src/app/app.component.scss @@ -0,0 +1,409 @@ +.app-container { + display: flex; + flex-direction: column; + height: 100vh; + background: #1e293b; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.app-toolbar { + position: sticky; + top: 0; + z-index: 10; + background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%) !important; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.app-icon { + margin-right: 12px; +} + +.app-title { + font-size: 20px; + font-weight: 500; + letter-spacing: 0.5px; +} + +.spacer { + flex: 1 1 auto; +} + +.header-info { + ::ng-deep { + mat-chip-set { + mat-chip { + background-color: rgba(255, 255, 255, 0.2) !important; + + .mdc-evolution-chip__action { + color: white !important; + } + + .mdc-evolution-chip__text-label { + color: white !important; + } + + mat-icon { + color: white !important; + } + } + } + } +} + +// Tab Group Styling - Dark Theme +.main-tabs { + flex: 1; + display: flex; + flex-direction: column; + background: #1e293b; + + ::ng-deep { + .mat-mdc-tab-body-wrapper { + flex: 1; + padding: 0; + background: #1e293b; + } + + .mat-mdc-tab-header { + background: #0f172a; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + border-bottom: 2px solid #334155; + } + + .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; + + .mdc-tab__text-label { + color: #cbd5e1; + } + + &:hover { + background: #1e293b; + + .mdc-tab__text-label { + color: #e2e8f0; + } + } + } + + .mat-mdc-tab.mdc-tab--active { + .mdc-tab__text-label { + color: #60a5fa; + font-weight: 600; + } + } + + .mat-mdc-tab-indicator { + .mdc-tab-indicator__content--underline { + background-color: #60a5fa; + height: 3px; + } + } + } +} + +// Tab Content Wrapper - Dark Theme +.tab-content-wrapper { + padding: 30px; + max-width: 1400px; + margin: 0 auto; + width: 100%; + min-height: calc(100vh - 180px); + background: #1e293b; +} + +// Content Header - Dark Theme +.content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 10px; + + h2 { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + font-size: 24px; + font-weight: 500; + color: #e2e8f0; + + mat-icon { + color: #60a5fa; + font-size: 28px; + width: 28px; + height: 28px; + } + } + + button { + mat-icon { + margin-right: 8px; + } + } +} + +// Toolbar styling +.toolbar { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: center; + flex-wrap: wrap; + + // Ensure buttons have proper styling + button { + &[color="accent"] { + background-color: #10b981 !important; + color: white !important; + } + } +} + +.count-badge { + background: #3b82f6; + color: #ffffff; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + margin-left: auto; +} + +// Filter Search Styling +.filter-search { + min-width: 250px; + + ::ng-deep { + .mat-mdc-text-field-wrapper { + background: #0f172a; + border-radius: 6px; + } + + .mat-mdc-form-field-input-control { + color: #e2e8f0; + } + + .mat-mdc-form-field-label { + color: #94a3b8; + } + + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #334155 !important; + } + + .mat-mdc-form-field:hover .mdc-notched-outline__leading, + .mat-mdc-form-field:hover .mdc-notched-outline__notch, + .mat-mdc-form-field:hover .mdc-notched-outline__trailing { + border-color: #60a5fa !important; + } + + .mat-mdc-form-field-icon-prefix, + .mat-mdc-form-field-icon-suffix { + color: #64748b; + } + } +} + +// Action buttons styling +.action-buttons { + display: flex; + gap: 4px; + + button { + color: #94a3b8; + + &:hover { + color: #e2e8f0; + background: #334155; + } + } +} + +// Artifacts Table Styling - Dark Theme +.artifacts-table { + width: 100%; + background: #0f172a; + border-radius: 8px; + overflow: hidden; + border: 1px solid #334155; + + th.mat-mdc-header-cell { + background: #1e293b; + color: #94a3b8; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 14px 12px; + border-bottom: 2px solid #334155; + } + + td.mat-mdc-cell { + padding: 16px 12px; + font-size: 14px; + color: #cbd5e1; + border-bottom: 1px solid #1e293b; + } + + tr.mat-mdc-row { + transition: background-color 0.2s ease; + background: #0f172a; + + &:hover { + background-color: #1e293b; + } + } + + td.filename-cell { + font-weight: 500; + + mat-icon { + color: #60a5fa; + font-size: 20px; + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 8px; + } + } + + .type-chip { + background-color: #3b82f6 !important; + color: #ffffff !important; + font-weight: 600; + font-size: 11px; + padding: 4px 12px; + height: auto; + } + + .text-muted { + color: #64748b; + } +} + +// Result Chips - Material Design style +mat-chip.result-pass { + --mdc-chip-elevated-container-color: #4caf50 !important; + --mdc-chip-label-text-color: #ffffff !important; + background-color: #4caf50 !important; + + .mdc-evolution-chip__action { + color: #ffffff !important; + } + + .mdc-evolution-chip__text-label { + color: #ffffff !important; + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + } +} + +mat-chip.result-fail { + --mdc-chip-elevated-container-color: #f44336 !important; + --mdc-chip-label-text-color: #ffffff !important; + background-color: #f44336 !important; + + .mdc-evolution-chip__action { + color: #ffffff !important; + } + + .mdc-evolution-chip__text-label { + color: #ffffff !important; + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + } +} + +mat-chip.result-skip { + --mdc-chip-elevated-container-color: #ff9800 !important; + --mdc-chip-label-text-color: #ffffff !important; + background-color: #ff9800 !important; + + .mdc-evolution-chip__action { + color: #ffffff !important; + } + + .mdc-evolution-chip__text-label { + color: #ffffff !important; + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + } +} + +mat-chip.result-error { + --mdc-chip-elevated-container-color: #e91e63 !important; + --mdc-chip-label-text-color: #ffffff !important; + background-color: #e91e63 !important; + + .mdc-evolution-chip__action { + color: #ffffff !important; + } + + .mdc-evolution-chip__text-label { + color: #ffffff !important; + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + } +} + +// 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; + } + } +} diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts new file mode 100644 index 0000000..a6b0ab9 --- /dev/null +++ b/frontend/src/app/app.component.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 0000000..e8ed161 --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,215 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +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 { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatTooltipModule } from '@angular/material/tooltip'; +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, + FormsModule, + MatToolbarModule, + MatTableModule, + MatTabsModule, + MatChipsModule, + MatIconModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatTooltipModule, + UploadFormComponent, + QueryFormComponent + ], + templateUrl: './app.component.html', + styleUrl: './app.component.scss' +}) +export class AppComponent implements OnInit, OnDestroy { + title = 'Obsidian - Test Artifact Data Lake'; + apiInfo: ApiInfo | null = null; + artifacts: Artifact[] = []; + filteredArtifacts: Artifact[] = []; + displayedColumns: string[] = ['id', 'filename', 'file_type', 'file_size', 'test_name', 'test_result', 'actions']; + selectedTabIndex = 0; + searchTerm: string = ''; + autoRefreshEnabled: boolean = true; + private autoRefreshInterval: any; + + constructor( + private apiService: ApiService, + private artifactService: ArtifactService + ) {} + + ngOnInit(): void { + this.loadApiInfo(); + this.loadArtifacts(); + this.startAutoRefresh(); + } + + ngOnDestroy(): void { + this.stopAutoRefresh(); + } + + 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; + this.filterTable(); + }, + error: (error) => { + console.error('Error loading artifacts:', error); + } + }); + } + + filterTable(): void { + if (!this.searchTerm) { + this.filteredArtifacts = this.artifacts; + return; + } + + const term = this.searchTerm.toLowerCase(); + this.filteredArtifacts = this.artifacts.filter(artifact => { + return ( + artifact.filename?.toLowerCase().includes(term) || + artifact.test_name?.toLowerCase().includes(term) || + artifact.test_suite?.toLowerCase().includes(term) || + artifact.file_type?.toLowerCase().includes(term) + ); + }); + } + + clearSearch(): void { + this.searchTerm = ''; + this.filterTable(); + } + + toggleAutoRefresh(): void { + this.autoRefreshEnabled = !this.autoRefreshEnabled; + if (this.autoRefreshEnabled) { + this.startAutoRefresh(); + } else { + this.stopAutoRefresh(); + } + } + + startAutoRefresh(): void { + this.stopAutoRefresh(); + if (this.autoRefreshEnabled) { + this.autoRefreshInterval = setInterval(() => { + if (this.selectedTabIndex === 0) { + this.loadArtifacts(); + } + }, 5000); + } + } + + stopAutoRefresh(): void { + if (this.autoRefreshInterval) { + clearInterval(this.autoRefreshInterval); + this.autoRefreshInterval = null; + } + } + + 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) { + alert('Please enter a number between 1 and 100'); + return; + } + + this.artifactService.generateSeedData(num).subscribe({ + next: (result: any) => { + alert(result.message || `Successfully generated ${num} artifacts`); + this.loadArtifacts(); + }, + error: (error) => { + alert('Error generating seed data: ' + error.message); + } + }); + } + + 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) => { + alert('Error downloading artifact: ' + error.message); + } + }); + } + + deleteArtifact(artifact: Artifact): void { + if (!confirm(`Are you sure you want to delete "${artifact.filename}"? This cannot be undone.`)) { + return; + } + + this.artifactService.deleteArtifact(artifact.id).subscribe({ + next: () => { + alert('Artifact deleted successfully'); + this.loadArtifacts(); + }, + error: (error) => { + alert('Error deleting artifact: ' + error.message); + } + }); + } + + onUploadSuccess(): void { + this.loadArtifacts(); + this.selectedTabIndex = 0; + } + + onQueryResults(artifacts: Artifact[]): void { + this.artifacts = artifacts; + this.filterTable(); + this.selectedTabIndex = 0; + } + + onFiltersChange(filters: any): void { + 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]; + } +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..d037d76 --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,13 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient() + ] +}; diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html new file mode 100644 index 0000000..7528372 --- /dev/null +++ b/frontend/src/app/app.html @@ -0,0 +1,342 @@ + + + + + + + + + + + +
+
+
+ +

Hello, {{ title() }}

+

Congratulations! Your app is running. 🎉

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

◆ Obsidian

+
+ {{ deploymentMode }} + {{ storageBackend }} +
+
+ + + + +
+ `, + styleUrls: ['./app.css'] +}) +export class AppComponent implements OnInit { + deploymentMode: string = ''; + storageBackend: string = ''; + + constructor(private artifactService: ArtifactService) {} + + ngOnInit() { + this.artifactService.getApiInfo().subscribe({ + next: (info) => { + this.deploymentMode = `Mode: ${info.deployment_mode}`; + this.storageBackend = `Storage: ${info.storage_backend}`; + }, + error: (err) => console.error('Failed to load API info:', err) + }); + } +} diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.css b/frontend/src/app/components/artifacts-list/artifacts-list.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.html b/frontend/src/app/components/artifacts-list/artifacts-list.html new file mode 100644 index 0000000..ad74e61 --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.html @@ -0,0 +1,190 @@ +
+
+ + + + + {{ filteredArtifacts.length }} artifacts + +
+ search + + +
+
+ +
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Sim Source + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + Artifacts + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + Date + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + Uploaded By + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + Actions
Loading artifacts...
No artifacts found. Upload some files to get started!
{{ artifact.sim_source_id || artifact.test_suite || '-' }} + {{ artifact.filename }} +
+ {{ tag }} +
+
{{ formatDate(artifact.created_at) }}{{ artifact.test_name || '-' }} +
+ + +
+
+
+ + +
+ + + diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.spec.ts b/frontend/src/app/components/artifacts-list/artifacts-list.spec.ts new file mode 100644 index 0000000..2a1cdbe --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ArtifactsList } from './artifacts-list'; + +describe('ArtifactsList', () => { + let component: ArtifactsList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtifactsList] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ArtifactsList); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/artifacts-list/artifacts-list.ts b/frontend/src/app/components/artifacts-list/artifacts-list.ts new file mode 100644 index 0000000..916dfdd --- /dev/null +++ b/frontend/src/app/components/artifacts-list/artifacts-list.ts @@ -0,0 +1,235 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ArtifactService } from '../../services/artifact'; +import { Artifact } from '../../models/artifact.model'; +import { interval, Subscription } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +@Component({ + selector: 'app-artifacts-list', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './artifacts-list.html', + styleUrls: ['./artifacts-list.css'] +}) +export class ArtifactsListComponent implements OnInit, OnDestroy { + artifacts: Artifact[] = []; + filteredArtifacts: Artifact[] = []; + selectedArtifact: Artifact | null = null; + searchTerm: string = ''; + + // Pagination + currentPage: number = 1; + pageSize: number = 25; + + // Auto-refresh + autoRefreshEnabled: boolean = true; + private refreshSubscription?: Subscription; + private readonly REFRESH_INTERVAL = 5000; // 5 seconds + + // Sorting + sortColumn: string | null = null; + sortDirection: 'asc' | 'desc' = 'asc'; + + loading: boolean = false; + error: string | null = null; + + constructor(private artifactService: ArtifactService) {} + + ngOnInit() { + this.loadArtifacts(); + this.startAutoRefresh(); + } + + ngOnDestroy() { + this.stopAutoRefresh(); + } + + loadArtifacts() { + this.loading = true; + this.error = null; + + const offset = (this.currentPage - 1) * this.pageSize; + this.artifactService.listArtifacts(this.pageSize, offset).subscribe({ + next: (artifacts) => { + this.artifacts = artifacts; + this.applyFilter(); + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load artifacts: ' + err.message; + this.loading = false; + } + }); + } + + applyFilter() { + if (!this.searchTerm) { + this.filteredArtifacts = [...this.artifacts]; + } else { + const term = this.searchTerm.toLowerCase(); + this.filteredArtifacts = this.artifacts.filter(artifact => + artifact.filename.toLowerCase().includes(term) || + (artifact.test_name && artifact.test_name.toLowerCase().includes(term)) || + (artifact.test_suite && artifact.test_suite.toLowerCase().includes(term)) || + (artifact.sim_source_id && artifact.sim_source_id.toLowerCase().includes(term)) + ); + } + + if (this.sortColumn) { + this.applySorting(); + } + } + + onSearch() { + this.applyFilter(); + } + + clearSearch() { + this.searchTerm = ''; + this.applyFilter(); + } + + sortTable(column: string) { + if (this.sortColumn === column) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortColumn = column; + this.sortDirection = 'asc'; + } + this.applySorting(); + } + + applySorting() { + if (!this.sortColumn) return; + + this.filteredArtifacts.sort((a, b) => { + const aVal = (a as any)[this.sortColumn!] || ''; + const bVal = (b as any)[this.sortColumn!] || ''; + + let comparison = 0; + if (this.sortColumn === 'created_at') { + comparison = new Date(aVal).getTime() - new Date(bVal).getTime(); + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + + return this.sortDirection === 'asc' ? comparison : -comparison; + }); + } + + previousPage() { + if (this.currentPage > 1) { + this.currentPage--; + this.loadArtifacts(); + } + } + + nextPage() { + this.currentPage++; + this.loadArtifacts(); + } + + toggleAutoRefresh() { + this.autoRefreshEnabled = !this.autoRefreshEnabled; + if (this.autoRefreshEnabled) { + this.startAutoRefresh(); + } else { + this.stopAutoRefresh(); + } + } + + private startAutoRefresh() { + if (!this.autoRefreshEnabled) return; + + this.refreshSubscription = interval(this.REFRESH_INTERVAL) + .pipe(switchMap(() => this.artifactService.listArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize))) + .subscribe({ + next: (artifacts) => { + this.artifacts = artifacts; + this.applyFilter(); + }, + error: (err) => console.error('Auto-refresh error:', err) + }); + } + + private stopAutoRefresh() { + if (this.refreshSubscription) { + this.refreshSubscription.unsubscribe(); + } + } + + showDetail(artifact: Artifact) { + this.selectedArtifact = artifact; + } + + closeDetail() { + this.selectedArtifact = null; + } + + downloadArtifact(artifact: Artifact, event: Event) { + event.stopPropagation(); + this.artifactService.downloadArtifact(artifact.id).subscribe({ + next: (blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = artifact.filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, + error: (err) => alert('Download failed: ' + err.message) + }); + } + + deleteArtifact(artifact: Artifact, event: Event) { + event.stopPropagation(); + if (!confirm(`Are you sure you want to delete ${artifact.filename}? This cannot be undone.`)) { + return; + } + + this.artifactService.deleteArtifact(artifact.id).subscribe({ + next: () => { + this.loadArtifacts(); + if (this.selectedArtifact?.id === artifact.id) { + this.closeDetail(); + } + }, + error: (err) => alert('Delete failed: ' + err.message) + }); + } + + generateSeedData() { + const count = prompt('How many artifacts to generate? (1-100)', '10'); + if (!count) return; + + const num = parseInt(count); + if (isNaN(num) || num < 1 || num > 100) { + alert('Please enter a number between 1 and 100'); + return; + } + + this.artifactService.generateSeedData(num).subscribe({ + next: (result) => { + alert(result.message); + this.loadArtifacts(); + }, + error: (err) => alert('Generation failed: ' + err.message) + }); + } + + formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + } + + formatDate(dateString: string): string { + return new Date(dateString).toLocaleString(); + } +} diff --git a/frontend/src/app/components/artifacts-table/artifacts-table.component.html b/frontend/src/app/components/artifacts-table/artifacts-table.component.html new file mode 100644 index 0000000..d619846 --- /dev/null +++ b/frontend/src/app/components/artifacts-table/artifacts-table.component.html @@ -0,0 +1,283 @@ +
+ +
+ LOADING STATE: {{ loading ? 'TRUE (Loading...)' : 'FALSE (Not Loading)' }} +
+ + + + +
+
+ + +
+ + + storage + {{ filteredArtifacts.length }} artifacts + + +
+
+
+ + +
+ +

Loading artifacts...

+
+ + +
+

Simple List Test

+

Filtered Artifacts Count: {{ filteredArtifacts.length }}

+
+ ID: {{ artifact.id }} | + Filename: {{ artifact.filename }} | + Type: {{ artifact.file_type }} +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID + {{ artifact.id }} + Event ID + + {{ artifact.event_id }} + + {{ artifact.id }} + Filename + + Type + {{ artifact.file_type }} + Size{{ formatBytes(artifact.file_size) }}Binaries +
+ + + code + {{ binary }} + + + + +{{ getHiddenBinariesCount(artifact.binaries) }} more + + - less + + + + + code + {{ binary }} + + +
+ + - + +
Test Name + {{ artifact.test_name || '-' }} + Suite + {{ artifact.test_suite || '-' }} + Result + + {{ getResultIcon(artifact.test_result) }} + {{ artifact.test_result }} + + - + Tags +
+ + + {{ tag }} + + + + +{{ getHiddenTagsCount(artifact.tags) }} more + + - less + + + + + {{ tag }} + + + + +
+ + + + +
Created + {{ formatDate(artifact.created_at) }} + Actions +
+ + +
+
+
+ inbox +

No artifacts found. Upload some files to get started!

+
+
+
+
+ + + + + + + +
+ + +
+ + + Artifact Details + + + + +

Artifact ID: {{ selectedArtifact.id }}

+

Filename: {{ selectedArtifact.filename }}

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/artifacts-table/artifacts-table.component.scss b/frontend/src/app/components/artifacts-table/artifacts-table.component.scss new file mode 100644 index 0000000..c708d8b --- /dev/null +++ b/frontend/src/app/components/artifacts-table/artifacts-table.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/artifacts-table/artifacts-table.component.ts b/frontend/src/app/components/artifacts-table/artifacts-table.component.ts new file mode 100644 index 0000000..c0b1f5f --- /dev/null +++ b/frontend/src/app/components/artifacts-table/artifacts-table.component.ts @@ -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 { + 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'; + } + } +} diff --git a/frontend/src/app/components/query-form/query-form.component.html b/frontend/src/app/components/query-form/query-form.component.html new file mode 100644 index 0000000..9ccd289 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.component.html @@ -0,0 +1,197 @@ + + + + search + Query Artifacts + + + Search and filter your artifact collection + + + + +
+ +
+

Basic Search

+
+ + Filename + + description + + + + File Type + + All Types + + {{ type.toUpperCase() }} + + + category + +
+
+ + +
+

Test Information

+
+ + Test Name + + quiz + + + + Test Suite + + folder + +
+ +
+ + Test Result + + All Results + + {{ getResultIcon(result) }} + {{ result | titlecase }} + + + assignment_turned_in + + + + Tags + + label + Comma-separated tags + +
+
+ + +
+

Date Range

+
+ + Start Date + + + + + + + End Date + + + + +
+
+ + +
+ + Searching artifacts... +
+ + +
+ + +
+
+
+
+ + + + + + filter_list + Active Filters + + + + + + + description + Filename: {{ queryForm.filename }} + + + category + Type: {{ queryForm.file_type }} + + + quiz + Test: {{ queryForm.test_name }} + + + folder + Suite: {{ queryForm.test_suite }} + + + assignment_turned_in + Result: {{ queryForm.test_result }} + + + label + Tags: {{ tagsInput }} + + + + \ No newline at end of file diff --git a/frontend/src/app/components/query-form/query-form.component.scss b/frontend/src/app/components/query-form/query-form.component.scss new file mode 100644 index 0000000..d0c3486 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/query-form/query-form.component.ts b/frontend/src/app/components/query-form/query-form.component.ts new file mode 100644 index 0000000..01f4a27 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.component.ts @@ -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(); + @Output() filtersChange = new EventEmitter(); + + 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'; + } + } +} diff --git a/frontend/src/app/components/query-form/query-form.css b/frontend/src/app/components/query-form/query-form.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/query-form/query-form.html b/frontend/src/app/components/query-form/query-form.html new file mode 100644 index 0000000..2529a96 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.html @@ -0,0 +1,102 @@ +
+

Query Artifacts

+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
diff --git a/frontend/src/app/components/query-form/query-form.spec.ts b/frontend/src/app/components/query-form/query-form.spec.ts new file mode 100644 index 0000000..b726afa --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QueryForm } from './query-form'; + +describe('QueryForm', () => { + let component: QueryForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [QueryForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(QueryForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/query-form/query-form.ts b/frontend/src/app/components/query-form/query-form.ts new file mode 100644 index 0000000..a209f38 --- /dev/null +++ b/frontend/src/app/components/query-form/query-form.ts @@ -0,0 +1,83 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; +import { ArtifactService } from '../../services/artifact'; +import { Artifact, ArtifactQuery } from '../../models/artifact.model'; + +@Component({ + selector: 'app-query-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './query-form.html', + styleUrls: ['./query-form.css'] +}) +export class QueryFormComponent { + queryForm: FormGroup; + @Output() resultsFound = new EventEmitter(); + + constructor( + private fb: FormBuilder, + private artifactService: ArtifactService + ) { + this.queryForm = this.fb.group({ + filename: [''], + file_type: [''], + test_name: [''], + test_suite: [''], + test_result: [''], + sim_source_id: [''], + tags: [''], + start_date: [''], + end_date: [''] + }); + } + + onSubmit() { + const query: ArtifactQuery = { + limit: 100, + offset: 0 + }; + + if (this.queryForm.value.filename) { + query.filename = this.queryForm.value.filename; + } + if (this.queryForm.value.file_type) { + query.file_type = this.queryForm.value.file_type; + } + if (this.queryForm.value.test_name) { + query.test_name = this.queryForm.value.test_name; + } + if (this.queryForm.value.test_suite) { + query.test_suite = this.queryForm.value.test_suite; + } + if (this.queryForm.value.test_result) { + query.test_result = this.queryForm.value.test_result; + } + if (this.queryForm.value.sim_source_id) { + query.sim_source_id = this.queryForm.value.sim_source_id; + } + if (this.queryForm.value.tags) { + query.tags = this.queryForm.value.tags + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t); + } + if (this.queryForm.value.start_date) { + query.start_date = new Date(this.queryForm.value.start_date).toISOString(); + } + if (this.queryForm.value.end_date) { + query.end_date = new Date(this.queryForm.value.end_date).toISOString(); + } + + this.artifactService.queryArtifacts(query).subscribe({ + next: (artifacts) => { + this.resultsFound.emit(artifacts); + }, + error: (err) => alert('Query failed: ' + err.message) + }); + } + + clearForm() { + this.queryForm.reset(); + } +} diff --git a/frontend/src/app/components/tab-navigation/tab-navigation.component.html b/frontend/src/app/components/tab-navigation/tab-navigation.component.html new file mode 100644 index 0000000..27352cd --- /dev/null +++ b/frontend/src/app/components/tab-navigation/tab-navigation.component.html @@ -0,0 +1,30 @@ + + + + + + view_list + Artifacts + + + + + + + cloud_upload + Upload + + + + + + + search + Query + + + diff --git a/frontend/src/app/components/tab-navigation/tab-navigation.component.scss b/frontend/src/app/components/tab-navigation/tab-navigation.component.scss new file mode 100644 index 0000000..ad49c34 --- /dev/null +++ b/frontend/src/app/components/tab-navigation/tab-navigation.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/tab-navigation/tab-navigation.component.ts b/frontend/src/app/components/tab-navigation/tab-navigation.component.ts new file mode 100644 index 0000000..7a5641e --- /dev/null +++ b/frontend/src/app/components/tab-navigation/tab-navigation.component.ts @@ -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(); + + 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); + } +} diff --git a/frontend/src/app/components/tag-manager/tag-manager.component.html b/frontend/src/app/components/tag-manager/tag-manager.component.html new file mode 100644 index 0000000..a43e2d0 --- /dev/null +++ b/frontend/src/app/components/tag-manager/tag-manager.component.html @@ -0,0 +1,151 @@ +
+ +
+ + + label + {{ tag }} + cancel + + +
+ + +
+ +
+ + + + + + new_label + Add New Tag + + + + +
+ + + Tag Name + + label + + + +
+ + +
+ + Predefined Scope + + No scope + + {{ scope | titlecase }} + + + + + + Custom Scope + + category + +
+
+ + +
+ + +
+
+
+
+ + + + + + library_add + Quick Add Existing Tags + + + Click to add existing tags + + + + +
+

General Tags

+ + + {{ tag.name }} + check + + +
+ + +
+

{{ scope | titlecase }} Tags

+ + + {{ tag.name }} + check + + +
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/tag-manager/tag-manager.component.scss b/frontend/src/app/components/tag-manager/tag-manager.component.scss new file mode 100644 index 0000000..e5c5ff9 --- /dev/null +++ b/frontend/src/app/components/tag-manager/tag-manager.component.scss @@ -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%; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/tag-manager/tag-manager.component.ts b/frontend/src/app/components/tag-manager/tag-manager.component.ts new file mode 100644 index 0000000..d2473ee --- /dev/null +++ b/frontend/src/app/components/tag-manager/tag-manager.component.ts @@ -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(); + + 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]; + } +} diff --git a/frontend/src/app/components/upload-form/upload-form.component.html b/frontend/src/app/components/upload-form/upload-form.component.html new file mode 100644 index 0000000..9c2e69a --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.component.html @@ -0,0 +1,199 @@ + + + + cloud_upload + Upload Artifact + + + + +
+ +
+
+ + + + Select File + + + Supported: CSV, JSON, binary files, PCAP + +
+ +
+ + + description + {{ selectedFile.name }} + + + data_usage + {{ selectedFile.size | number }} bytes + + +
+
+ + +
+ + Event ID + + event + Groups multiple artifacts under the same event + + + + Test Name + + quiz + +
+ + +
+ + Test Suite + + category + + + + Test Result + + -- Select -- + + {{ getResultIcon(result) }} + {{ result | titlecase }} + + + assignment_turned_in + +
+ + +
+ + Version + + tag + + + + Associated Binaries + + code + Comma-separated list of binaries/files + +
+ + + + Tags + + label + Comma-separated tags + + + + + Description + + description + + + +
+

Advanced Configuration

+ + + Test Config (JSON) + + settings + + + + Custom Metadata (JSON) + + data_object + +
+ + + + + +
+ +
+
+ + +
+ + + + {{ uploadStatusType === 'success' ? 'check_circle' : 'error' }} + + {{ uploadStatus }} + + +
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/upload-form/upload-form.component.scss b/frontend/src/app/components/upload-form/upload-form.component.scss new file mode 100644 index 0000000..5ca9e8c --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.component.scss @@ -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%; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/upload-form/upload-form.component.ts b/frontend/src/app/components/upload-form/upload-form.component.ts new file mode 100644 index 0000000..eeb5a7a --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.component.ts @@ -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(); + + 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'; + } + } +} diff --git a/frontend/src/app/components/upload-form/upload-form.css b/frontend/src/app/components/upload-form/upload-form.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/upload-form/upload-form.html b/frontend/src/app/components/upload-form/upload-form.html new file mode 100644 index 0000000..e52ddc0 --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.html @@ -0,0 +1,109 @@ +
+

Upload Artifact

+
+
+ + + Supported: CSV, JSON, binary files, PCAP +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + Use same ID for multiple artifacts from same source +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ {{ uploadStatus.message }} +
+
diff --git a/frontend/src/app/components/upload-form/upload-form.spec.ts b/frontend/src/app/components/upload-form/upload-form.spec.ts new file mode 100644 index 0000000..b38c844 --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UploadForm } from './upload-form'; + +describe('UploadForm', () => { + let component: UploadForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UploadForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UploadForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/upload-form/upload-form.ts b/frontend/src/app/components/upload-form/upload-form.ts new file mode 100644 index 0000000..69caa34 --- /dev/null +++ b/frontend/src/app/components/upload-form/upload-form.ts @@ -0,0 +1,132 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ArtifactService } from '../../services/artifact'; + +@Component({ + selector: 'app-upload-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './upload-form.html', + styleUrls: ['./upload-form.css'] +}) +export class UploadFormComponent { + uploadForm: FormGroup; + selectedFile: File | null = null; + uploading: boolean = false; + uploadStatus: { message: string, success: boolean } | null = null; + + constructor( + private fb: FormBuilder, + private artifactService: ArtifactService + ) { + this.uploadForm = this.fb.group({ + file: [null, Validators.required], + sim_source: ['', Validators.required], + uploaded_by: ['', Validators.required], + sim_source_id: [''], + tags: ['', Validators.required], + test_result: [''], + version: [''], + description: [''], + test_config: [''], + custom_metadata: [''] + }); + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.selectedFile = input.files[0]; + this.uploadForm.patchValue({ file: this.selectedFile }); + } + } + + onSubmit() { + if (!this.uploadForm.valid || !this.selectedFile) { + this.showStatus('Please fill in all required fields and select a file', false); + return; + } + + // Validate JSON fields + const testConfig = this.uploadForm.value.test_config; + const customMetadata = this.uploadForm.value.custom_metadata; + + if (testConfig) { + try { + JSON.parse(testConfig); + } catch (e) { + this.showStatus('Invalid Test Config JSON', false); + return; + } + } + + if (customMetadata) { + try { + JSON.parse(customMetadata); + } catch (e) { + this.showStatus('Invalid Custom Metadata JSON', false); + return; + } + } + + const formData = new FormData(); + formData.append('file', this.selectedFile); + formData.append('test_suite', this.uploadForm.value.sim_source); + formData.append('test_name', this.uploadForm.value.uploaded_by); + + if (this.uploadForm.value.sim_source_id) { + formData.append('sim_source_id', this.uploadForm.value.sim_source_id); + } + + // Parse and append tags as JSON array + if (this.uploadForm.value.tags) { + const tagsArray = this.uploadForm.value.tags + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t); + formData.append('tags', JSON.stringify(tagsArray)); + } + + if (this.uploadForm.value.test_result) { + formData.append('test_result', this.uploadForm.value.test_result); + } + + if (this.uploadForm.value.version) { + formData.append('version', this.uploadForm.value.version); + } + + if (this.uploadForm.value.description) { + formData.append('description', this.uploadForm.value.description); + } + + if (testConfig) { + formData.append('test_config', testConfig); + } + + if (customMetadata) { + formData.append('custom_metadata', customMetadata); + } + + this.uploading = true; + this.artifactService.uploadArtifact(formData).subscribe({ + next: (artifact) => { + this.showStatus(`Successfully uploaded: ${artifact.filename}`, true); + this.uploadForm.reset(); + this.selectedFile = null; + this.uploading = false; + }, + error: (err) => { + this.showStatus('Upload failed: ' + err.error?.detail || err.message, false); + this.uploading = false; + } + }); + } + + private showStatus(message: string, success: boolean) { + this.uploadStatus = { message, success }; + setTimeout(() => { + this.uploadStatus = null; + }, 5000); + } +} diff --git a/frontend/src/app/models/artifact.interface.ts b/frontend/src/app/models/artifact.interface.ts new file mode 100644 index 0000000..612bd2b --- /dev/null +++ b/frontend/src/app/models/artifact.interface.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/app/models/artifact.model.ts b/frontend/src/app/models/artifact.model.ts new file mode 100644 index 0000000..16d42cd --- /dev/null +++ b/frontend/src/app/models/artifact.model.ts @@ -0,0 +1,40 @@ +export interface Artifact { + id: number; + filename: string; + file_type: string; + file_size: number; + storage_path: string; + content_type: string | null; + test_name: string | null; + test_suite: string | null; + test_config: any; + test_result: string | null; + sim_source_id: string | null; + custom_metadata: any; + description: string | null; + tags: string[] | null; + created_at: string; + updated_at: string; + version: string | null; + parent_id: number | null; +} + +export interface ArtifactQuery { + filename?: string; + file_type?: string; + test_name?: string; + test_suite?: string; + test_result?: string; + sim_source_id?: string; + tags?: string[]; + start_date?: string; + end_date?: string; + limit?: number; + offset?: number; +} + +export interface ApiInfo { + deployment_mode: string; + storage_backend: string; + version: string; +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts new file mode 100644 index 0000000..1aec64c --- /dev/null +++ b/frontend/src/app/services/api.service.ts @@ -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 { + return this.http.get(`${this.API_BASE}/api`); + } +} diff --git a/frontend/src/app/services/artifact.service.ts b/frontend/src/app/services/artifact.service.ts new file mode 100644 index 0000000..d042efc --- /dev/null +++ b/frontend/src/app/services/artifact.service.ts @@ -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([]); + public artifacts$ = this.artifactsSubject.asObservable(); + + constructor(private http: HttpClient) { } + + getArtifacts(limit: number = 1000, offset: number = 0): Observable { + const params = new HttpParams() + .set('limit', limit.toString()) + .set('offset', offset.toString()); + + return this.http.get(`${this.API_BASE}/artifacts/`, { params }); + } + + getArtifact(id: number): Observable { + return this.http.get(`${this.API_BASE}/artifacts/${id}`); + } + + uploadArtifact(formData: FormData): Observable { + return this.http.post(`${this.API_BASE}/artifacts/upload`, formData); + } + + deleteArtifact(id: number): Observable { + return this.http.delete(`${this.API_BASE}/artifacts/${id}`); + } + + downloadArtifact(id: number): Observable { + return this.http.get(`${this.API_BASE}/artifacts/${id}/download`, { responseType: 'blob' }); + } + + queryArtifacts(query: ArtifactQuery): Observable { + return this.http.post(`${this.API_BASE}/artifacts/query`, query); + } + + generateSeedData(count: number): Observable { + 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 { + return this.http.post(`${this.API_BASE}/artifacts/${artifactId}/tags`, tag); + } + + removeTag(artifactId: number, tagId: number): Observable { + return this.http.delete(`${this.API_BASE}/artifacts/${artifactId}/tags/${tagId}`); + } + + getAllTags(): Observable { + return this.http.get(`${this.API_BASE}/tags`); + } + + createTag(tag: Tag): Observable { + return this.http.post(`${this.API_BASE}/tags`, tag); + } +} diff --git a/frontend/src/app/services/artifact.ts b/frontend/src/app/services/artifact.ts new file mode 100644 index 0000000..6560f15 --- /dev/null +++ b/frontend/src/app/services/artifact.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Artifact, ArtifactQuery, ApiInfo } from '../models/artifact.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ArtifactService { + private apiUrl = '/api/v1/artifacts'; + private baseUrl = '/api'; + + constructor(private http: HttpClient) {} + + getApiInfo(): Observable { + return this.http.get(this.baseUrl); + } + + listArtifacts(limit: number = 100, offset: number = 0): Observable { + const params = new HttpParams() + .set('limit', limit.toString()) + .set('offset', offset.toString()); + return this.http.get(this.apiUrl + '/', { params }); + } + + getArtifact(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + queryArtifacts(query: ArtifactQuery): Observable { + return this.http.post(`${this.apiUrl}/query`, query); + } + + uploadArtifact(formData: FormData): Observable { + return this.http.post(`${this.apiUrl}/upload`, formData); + } + + downloadArtifact(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}/download`, { + responseType: 'blob' + }); + } + + deleteArtifact(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + generateSeedData(count: number): Observable { + return this.http.post(`/api/v1/seed/generate/${count}`, {}); + } +} diff --git a/frontend/src/app/services/notification.service.ts b/frontend/src/app/services/notification.service.ts new file mode 100644 index 0000000..2c5d249 --- /dev/null +++ b/frontend/src/app/services/notification.service.ts @@ -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 { + 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); + } + }); + }); + } +} \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..d4f82fb --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,15 @@ + + + + + Frontend + + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..35b00f3 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/static/css/styles.css b/frontend/src/styles.css similarity index 91% rename from static/css/styles.css rename to frontend/src/styles.css index 58bcca3..0d04177 100644 --- a/static/css/styles.css +++ b/frontend/src/styles.css @@ -524,6 +524,49 @@ code { stroke: currentColor; } +/* Artifact link styles - softer blue */ +.artifact-link { + color: #93c5fd; + text-decoration: none; + transition: color 0.3s; +} + +.artifact-link:hover { + color: #bfdbfe; + text-decoration: underline; +} + +/* Clickable row cursor */ +tr.clickable { + cursor: pointer; +} + +/* Search icon color */ +.search-icon { + color: #64748b; +} + +/* Material Icons */ +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 20px; + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + vertical-align: middle; +} + +.material-icons.md-16 { font-size: 16px; } +.material-icons.md-18 { font-size: 18px; } +.material-icons.md-20 { font-size: 20px; } +.material-icons.md-24 { font-size: 24px; } + @media (max-width: 768px) { .form-row { grid-template-columns: 1fr; diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..342c4b7 --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,70 @@ +/* Global styles for Obsidian - Dark Theme from main branch */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #0f172a; + min-height: 100vh; + padding: 20px; + color: #e2e8f0; +} + +html, body { + height: 100%; + margin: 0; +} + +/* 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; +} + +/* Main color variables matching main branch */ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text-primary: #e2e8f0; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --text-dark-muted: #64748b; + --accent-blue: #60a5fa; + --accent-blue-dark: #3b82f6; + --accent-blue-darker: #2563eb; + --gradient-start: #1e3a8a; + --gradient-end: #4338ca; + --success-bg: #064e3b; + --success-text: #6ee7b7; + --error-bg: #7f1d1d; + --error-text: #fca5a5; + --warning-bg: #78350f; + --warning-text: #fcd34d; + --badge-bg: #1e3a8a; + --badge-text: #93c5fd; +} diff --git a/frontend/switch-registry.ps1 b/frontend/switch-registry.ps1 new file mode 100644 index 0000000..ab6ebc4 --- /dev/null +++ b/frontend/switch-registry.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [Parameter(Position=0)] + [ValidateSet("public", "artifactory")] + [string]$RegistryType = "public" +) + +$ErrorActionPreference = "Stop" + +switch ($RegistryType) { + "public" { + Write-Host "Switching to public npm registry..." -ForegroundColor Yellow + Copy-Item ".npmrc.public" ".npmrc" -Force + Write-Host "[OK] Now using registry.npmjs.org" -ForegroundColor Green + Write-Host "" + Write-Host "To install dependencies:" -ForegroundColor White + Write-Host " npm ci --force" -ForegroundColor Cyan + } + "artifactory" { + Write-Host "Switching to Artifactory registry..." -ForegroundColor Yellow + Copy-Item ".npmrc.artifactory" ".npmrc" -Force + Write-Host "[OK] Now using Artifactory registry" -ForegroundColor Green + Write-Host "" + Write-Host "Make sure to set environment variables if authentication is required:" -ForegroundColor White + Write-Host ' $env:ARTIFACTORY_AUTH_TOKEN = "your_token"' -ForegroundColor Cyan + Write-Host "" + Write-Host "To install dependencies:" -ForegroundColor White + Write-Host " npm ci --force" -ForegroundColor Cyan + } +} + +Write-Host "" +Write-Host "Current .npmrc contents:" -ForegroundColor White +Get-Content ".npmrc" diff --git a/frontend/switch-registry.sh b/frontend/switch-registry.sh new file mode 100644 index 0000000..b944542 --- /dev/null +++ b/frontend/switch-registry.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Script to switch between npm registries +# Usage: ./switch-registry.sh [public|artifactory] + +set -e + +REGISTRY_TYPE=${1:-public} + +case $REGISTRY_TYPE in + public) + echo "Switching to public npm registry..." + cp .npmrc.public .npmrc + echo "✓ Now using registry.npmjs.org" + echo "" + echo "To install dependencies:" + echo " npm ci --force" + ;; + artifactory) + echo "Switching to Artifactory registry..." + cp .npmrc.artifactory .npmrc + echo "✓ Now using Artifactory registry" + echo "" + echo "Make sure to set environment variables if authentication is required:" + echo " export ARTIFACTORY_AUTH_TOKEN=your_token" + echo "" + echo "To install dependencies:" + echo " npm ci --force" + ;; + *) + echo "Usage: $0 [public|artifactory]" + echo "" + echo "Options:" + echo " public - Use registry.npmjs.org (default)" + echo " artifactory - Use Artifactory npm registry" + exit 1 + ;; +esac + +echo "" +echo "Current .npmrc contents:" +cat .npmrc diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..3775b37 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5525117 --- /dev/null +++ b/frontend/tsconfig.json @@ -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 + } +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..5fb748d --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..47d6474 --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} \ No newline at end of file diff --git a/quickstart.bat b/quickstart.bat deleted file mode 100644 index abdae8d..0000000 --- a/quickstart.bat +++ /dev/null @@ -1,106 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -echo ========================================= -echo Obsidian - 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 Building and starting services with Docker Compose... -%COMPOSE_CMD% up -d --build - -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 diff --git a/quickstart.ps1 b/quickstart.ps1 index 490a2c5..4a98bc5 100644 --- a/quickstart.ps1 +++ b/quickstart.ps1 @@ -1,129 +1,168 @@ -# Test Artifact Data Lake - Quick Start (PowerShell) +[CmdletBinding()] +param( + [switch]$Rebuild, + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +if ($Help) { + Write-Host "=========================================" -ForegroundColor Cyan + Write-Host "Obsidian - 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 " -Help Show this help message" -ForegroundColor White + Write-Host "" + Write-Host "NPM Registry:" -ForegroundColor Yellow + Write-Host " Uses your machine's npm configuration automatically" -ForegroundColor White + Write-Host " npm install runs on host before Docker build" -ForegroundColor White + Write-Host "" + Write-Host "Brings up the complete stack: database, backend API, and frontend" -ForegroundColor Green + Write-Host "" + exit 0 +} Write-Host "=========================================" -ForegroundColor Cyan Write-Host "Obsidian - Quick Start" -ForegroundColor Cyan Write-Host "=========================================" -ForegroundColor Cyan Write-Host "" +# Copy user's .npmrc to frontend directory if it exists +$UserNpmrc = Join-Path $env:USERPROFILE ".npmrc" +$FrontendNpmrc = "frontend\.npmrc" + +if (Test-Path $UserNpmrc) { + Write-Host "Copying npm registry config for Docker build..." -ForegroundColor Yellow + Copy-Item $UserNpmrc $FrontendNpmrc -Force + Write-Host "[OK] Using custom npm registry configuration" -ForegroundColor Green +} else { + if (Test-Path $FrontendNpmrc) { + Remove-Item $FrontendNpmrc -Force + } + Write-Host "[INFO] Using default npm registry" -ForegroundColor Yellow +} +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 +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 } -# Determine Docker Compose command -$composeCmd = "docker-compose" -try { - docker-compose version | Out-Null -} catch { - # Try new docker compose syntax +# 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 + & 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")) { +if (-not (Test-Path ".env")) { Write-Host "Creating .env file from .env.example..." -ForegroundColor Yellow - Copy-Item .env.example .env + 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 "Building and starting services with Docker Compose..." -ForegroundColor Yellow -# Start services with rebuild -if ($composeCmd -eq "docker-compose") { - docker-compose up -d --build +# Handle rebuild logic +if ($Rebuild) { + Write-Host "Rebuilding containers..." -ForegroundColor Yellow + Write-Host "Stopping existing containers..." -ForegroundColor White + + if ($ComposeCmd -eq "docker compose") { + & docker compose down + Write-Host "Removing existing images for rebuild..." -ForegroundColor White + & docker compose down --rmi local + Write-Host "Building and starting all services..." -ForegroundColor White + & docker compose up -d --build + } else { + & docker-compose down + Write-Host "Removing existing images for rebuild..." -ForegroundColor White + & docker-compose down --rmi local + Write-Host "Building and starting all services..." -ForegroundColor White + & docker-compose up -d --build + } } else { - docker compose up -d --build -} - -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 "Starting all 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 +Start-Sleep -Seconds 20 Write-Host "" Write-Host "=========================================" -ForegroundColor Cyan -Write-Host "Services are running!" -ForegroundColor Green +Write-Host "Complete Stack is 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 "Application: 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 "To view logs: $composeCmd logs -f" -ForegroundColor Cyan -Write-Host "To stop: $composeCmd down" -ForegroundColor Cyan +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 "Testing the API..." -ForegroundColor Yellow +Write-Host "Testing the API..." -ForegroundColor Cyan Write-Host "=========================================" -ForegroundColor Cyan Write-Host "" -# Wait a bit more for API +# Wait a bit more for API to be fully ready Start-Sleep -Seconds 5 # Test health endpoint try { - $response = Invoke-WebRequest -Uri "http://localhost:8000/health" -UseBasicParsing -TimeoutSec 5 - if ($response.Content -like "*healthy*") { + $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 "" - 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" + Write-Host "All services are ready!" -ForegroundColor Green + 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." -ForegroundColor Yellow - Write-Host "Please wait a moment and check http://localhost:8000" -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! " -NoNewline -Write-Host "🚀" -ForegroundColor Green +Write-Host "Setup complete!" -ForegroundColor Green +Write-Host "Open http://localhost:8000 in your browser" -ForegroundColor Yellow 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 "" diff --git a/quickstart.sh b/quickstart.sh old mode 100755 new mode 100644 index a0fed12..8cb54a4 --- a/quickstart.sh +++ b/quickstart.sh @@ -14,11 +14,64 @@ if ! command -v docker &> /dev/null; then fi # Check if Docker Compose is installed -if ! command -v docker-compose &> /dev/null; then +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 + +while [[ $# -gt 0 ]]; do + case $1 in + --rebuild) + REBUILD=true + shift + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --rebuild Force rebuild of all containers" + echo " --help Show this help message" + echo "" + echo "NPM Registry:" + echo " Uses your machine's npm configuration automatically" + echo " npm install runs on host before Docker build" + echo "" + echo "Brings up the complete stack: database, backend API, and frontend" + echo "" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Copy user's .npmrc to frontend directory if it exists +USER_NPMRC="${HOME}/.npmrc" +FRONTEND_NPMRC="frontend/.npmrc" + +if [ -f "$USER_NPMRC" ]; then + echo "Copying npm registry config for Docker build..." + cp "$USER_NPMRC" "$FRONTEND_NPMRC" + echo "✓ Using custom npm registry configuration" +else + if [ -f "$FRONTEND_NPMRC" ]; then + rm -f "$FRONTEND_NPMRC" + fi + echo "ℹ Using default npm registry" +fi +echo "" + # Create .env file if it doesn't exist if [ ! -f .env ]; then echo "Creating .env file from .env.example..." @@ -29,26 +82,38 @@ else fi echo "" -echo "Building and starting services with Docker Compose..." -docker-compose up -d --build + +# Stop existing containers if rebuild is requested +if [ "$REBUILD" = true ]; then + echo "🔄 Rebuilding containers..." + echo "Stopping existing containers..." + $COMPOSE_CMD down + echo "Removing existing images for rebuild..." + $COMPOSE_CMD down --rmi local 2>/dev/null || true + echo "Building and starting all services..." + $COMPOSE_CMD up -d --build +else + echo "Starting all services..." + $COMPOSE_CMD up -d +fi echo "" echo "Waiting for services to be ready..." -sleep 10 +sleep 20 echo "" echo "=========================================" -echo "Services are running!" +echo "Complete Stack is running! 🚀" echo "=========================================" echo "" -echo "API: http://localhost:8000" +echo "Application: 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: $COMPOSE_CMD logs -f" +echo "To stop: $COMPOSE_CMD down" echo "" echo "=========================================" echo "Testing the API..." @@ -62,6 +127,8 @@ sleep 5 if curl -s http://localhost:8000/health | grep -q "healthy"; then echo "✓ API is healthy!" echo "" + echo "🎯 All services are ready!" + echo "" echo "Example: Upload a test file" echo "----------------------------" echo 'echo "test,data" > test.csv' @@ -77,4 +144,5 @@ fi echo "=========================================" echo "Setup complete! 🚀" +echo "Open http://localhost:8000 in your browser" echo "=========================================" diff --git a/scripts/quickstart-build.sh b/scripts/quickstart-build.sh new file mode 100644 index 0000000..3d0fb9a --- /dev/null +++ b/scripts/quickstart-build.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +set -e + +echo "=========================================" +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." + 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 + +# 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 "=========================================" +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 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 -f docker-compose.production.yml logs -f" +echo "To stop: docker-compose -f docker-compose.production.yml down" +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 "" + +# Test health endpoint +sleep 5 +if curl -s http://localhost:8000/health | grep -q "healthy"; then + echo "✓ API is healthy!" + echo "" + echo "=========================================" + echo "Setup complete! 🚀" + echo "=========================================" +else + echo "⚠ API is not responding yet. Please wait a moment and check http://localhost:80" +fi \ No newline at end of file diff --git a/static/index.html b/static/index.html deleted file mode 100644 index ce1c454..0000000 --- a/static/index.html +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - Obsidian - Test Artifact Data Lake - - - - -
-
-

◆ Obsidian

-
- - -
-
- - - - -
-
- - - - - - -
- - - -
-
- -
- - - - - - - - - - - - - - - -
- Sim Source - - Artifacts - - Date - - Uploaded By - Actions
Loading artifacts...
-
- - -
- - -
-
-

Upload Artifact

-
-
- - - Supported: CSV, JSON, binary files, PCAP -
- -
-
- - -
-
- - -
-
- -
-
- - - Use same ID for multiple artifacts from same source -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- -
- - -
- - -
-
-
-
- - -
-
-

Query Artifacts

-
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - - -
-
-
- - - -
- - - - - diff --git a/static/js/app.js b/static/js/app.js deleted file mode 100644 index 07cb8fd..0000000 --- a/static/js/app.js +++ /dev/null @@ -1,592 +0,0 @@ -// API Base URL -const API_BASE = '/api/v1'; - -// Pagination -let currentPage = 1; -let pageSize = 25; -let totalArtifacts = 0; - -// Auto-refresh -let autoRefreshEnabled = true; -let autoRefreshInterval = null; -const REFRESH_INTERVAL_MS = 5000; // 5 seconds - -// Sorting and filtering -let allArtifacts = []; // Store all artifacts for client-side sorting/filtering -let currentSortColumn = null; -let currentSortDirection = 'asc'; - -// Load API info on page load -window.addEventListener('DOMContentLoaded', () => { - loadApiInfo(); - loadArtifacts(); - startAutoRefresh(); -}); - -// 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(); - - allArtifacts = artifacts; // Store for sorting/filtering - displayArtifacts(artifacts); - updatePagination(artifacts.length); - } catch (error) { - console.error('Error loading artifacts:', error); - document.getElementById('artifacts-tbody').innerHTML = ` - - Error loading artifacts: ${error.message} - - `; - } -} - -// Display artifacts in table -function displayArtifacts(artifacts) { - const tbody = document.getElementById('artifacts-tbody'); - - if (artifacts.length === 0) { - tbody.innerHTML = 'No artifacts found. Upload some files to get started!'; - document.getElementById('artifact-count').textContent = '0 artifacts'; - return; - } - - // Apply current sort if active - let displayedArtifacts = artifacts; - if (currentSortColumn) { - displayedArtifacts = applySorting([...artifacts]); - } - - tbody.innerHTML = displayedArtifacts.map(artifact => ` - - ${artifact.sim_source_id || artifact.test_suite || '-'} - - - ${escapeHtml(artifact.filename)} - - ${artifact.tags && artifact.tags.length > 0 ? `
${formatTags(artifact.tags)}
` : ''} - - ${formatDate(artifact.created_at)} - ${artifact.test_name || '-'} - -
- - -
- - - `).join(''); - - document.getElementById('artifact-count').textContent = `${displayedArtifacts.length} artifacts`; - - // Re-initialize Lucide icons for dynamically added content - lucide.createIcons(); -} - -// Format result badge -function formatResult(result) { - if (!result) return '-'; - const className = `result-badge result-${result}`; - return `${result}`; -} - -// Format tags -function formatTags(tags) { - if (!tags || tags.length === 0) return '-'; - return tags.map(tag => `${escapeHtml(tag)}`).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 = ` -
-
ID
-
${artifact.id}
-
-
-
Filename
-
${escapeHtml(artifact.filename)}
-
-
-
File Type
-
${artifact.file_type}
-
-
-
Size
-
${formatBytes(artifact.file_size)}
-
-
-
Storage Path
-
${artifact.storage_path}
-
-
-
Uploaded By
-
${artifact.test_name || '-'}
-
-
-
Sim Source
-
${artifact.test_suite || '-'}
-
-
-
Test Result
-
${formatResult(artifact.test_result)}
-
- ${artifact.test_config ? ` -
-
Test Config
-
${JSON.stringify(artifact.test_config, null, 2)}
-
- ` : ''} - ${artifact.custom_metadata ? ` -
-
Custom Metadata
-
${JSON.stringify(artifact.custom_metadata, null, 2)}
-
- ` : ''} - ${artifact.description ? ` -
-
Description
-
${escapeHtml(artifact.description)}
-
- ` : ''} - ${artifact.tags && artifact.tags.length > 0 ? ` -
-
Tags
-
${formatTags(artifact.tags)}
-
- ` : ''} -
-
Version
-
${artifact.version || '-'}
-
-
-
Created
-
${formatDate(artifact.created_at)}
-
-
-
Updated
-
${formatDate(artifact.updated_at)}
-
-
- - -
- `; - - document.getElementById('detail-modal').classList.add('active'); - - // Re-initialize Lucide icons for modal content - lucide.createIcons(); - } 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', 'sim_source_id']; - 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); -} - -// Auto-refresh functions -function startAutoRefresh() { - if (autoRefreshInterval) { - clearInterval(autoRefreshInterval); - } - - if (autoRefreshEnabled) { - autoRefreshInterval = setInterval(() => { - // Only refresh if on the artifacts tab - const artifactsTab = document.getElementById('artifacts-tab'); - if (artifactsTab && artifactsTab.classList.contains('active')) { - loadArtifacts(pageSize, (currentPage - 1) * pageSize); - } - }, REFRESH_INTERVAL_MS); - } -} - -function toggleAutoRefresh() { - autoRefreshEnabled = !autoRefreshEnabled; - - const toggleBtn = document.getElementById('auto-refresh-toggle'); - if (autoRefreshEnabled) { - toggleBtn.textContent = 'Auto-refresh: ON'; - toggleBtn.classList.remove('btn-secondary'); - toggleBtn.classList.add('btn-success'); - startAutoRefresh(); - } else { - toggleBtn.textContent = 'Auto-refresh: OFF'; - toggleBtn.classList.remove('btn-success'); - toggleBtn.classList.add('btn-secondary'); - if (autoRefreshInterval) { - clearInterval(autoRefreshInterval); - autoRefreshInterval = null; - } - } -} - -// Apply sorting to artifacts array -function applySorting(artifacts) { - if (!currentSortColumn) return artifacts; - - return artifacts.sort((a, b) => { - let aVal = a[currentSortColumn] || ''; - let bVal = b[currentSortColumn] || ''; - - // Handle date sorting - if (currentSortColumn === 'created_at') { - aVal = new Date(aVal).getTime(); - bVal = new Date(bVal).getTime(); - } else { - // String comparison (case insensitive) - aVal = String(aVal).toLowerCase(); - bVal = String(bVal).toLowerCase(); - } - - if (aVal < bVal) return currentSortDirection === 'asc' ? -1 : 1; - if (aVal > bVal) return currentSortDirection === 'asc' ? 1 : -1; - return 0; - }); -} - -// Sorting functionality -function sortTable(column) { - // Toggle sort direction if clicking same column - if (currentSortColumn === column) { - currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; - } else { - currentSortColumn = column; - currentSortDirection = 'asc'; - } - - // Update sort indicators - document.querySelectorAll('th.sortable').forEach(th => { - th.classList.remove('sort-asc', 'sort-desc'); - }); - - const sortedHeader = event.target.closest('th'); - sortedHeader.classList.add(`sort-${currentSortDirection}`); - - // Apply filter and sort - filterTable(); -} - -// Filtering functionality - searches across all columns -function filterTable() { - const searchTerm = document.getElementById('filter-search').value.toLowerCase(); - - const filteredArtifacts = allArtifacts.filter(artifact => { - if (!searchTerm) return true; - - // Search across all relevant fields - const searchableText = [ - artifact.test_suite || '', - artifact.filename || '', - artifact.test_name || '', - formatDate(artifact.created_at) - ].join(' ').toLowerCase(); - - return searchableText.includes(searchTerm); - }); - - displayArtifacts(filteredArtifacts); -} - -// Clear all filters -function clearFilters() { - document.getElementById('filter-search').value = ''; - filterTable(); -} diff --git a/utils/seed_data.py b/utils/seed_data.py index d653a2b..105bef5 100755 --- a/utils/seed_data.py +++ b/utils/seed_data.py @@ -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""" @@ -184,6 +201,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. @@ -198,7 +258,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}") @@ -295,42 +359,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()