Switch to angular #1

Closed
pratik wants to merge 18 commits from f/updates into main
75 changed files with 5870 additions and 1951 deletions

View File

@@ -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": []
}
}

View File

@@ -1,3 +1,4 @@
# Python cache
__pycache__ __pycache__
*.pyc *.pyc
*.pyo *.pyo
@@ -5,15 +6,50 @@ __pycache__
.Python .Python
env/ env/
venv/ 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
.env .env
# Git
.git .git
.gitignore .gitignore
# Documentation
*.md *.md
# IDE files
.vscode .vscode
.idea .idea
# Logs
*.log *.log
# OS files
.DS_Store .DS_Store
# Configuration
helm/ helm/
.gitlab-ci.yml .gitlab-ci.yml
docker-compose.yml docker-compose.yml
# Development files
frontend/.vscode
frontend/src/**/*.spec.ts
# Runtime data
*.pid
*.seed
*.pid.lock
# Coverage
coverage

12
.gitignore vendored
View File

@@ -86,3 +86,15 @@ helm/charts/
tmp/ tmp/
temp/ temp/
*.tmp *.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

View File

@@ -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 FROM python:3.11-alpine
WORKDIR /app WORKDIR /app
# Install system dependencies for Alpine # Install system dependencies for Alpine
# Alpine uses apk instead of apt-get and is lighter/faster
RUN apk add --no-cache \ RUN apk add --no-cache \
gcc \ gcc \
musl-dev \ musl-dev \
postgresql-dev \ postgresql-dev \
postgresql-client \ postgresql-client \
linux-headers linux-headers \
curl
# Copy requirements and install Python dependencies # Copy requirements and install Python dependencies
COPY requirements.txt . COPY requirements.txt .
@@ -20,7 +47,9 @@ COPY app/ ./app/
COPY utils/ ./utils/ COPY utils/ ./utils/
COPY alembic/ ./alembic/ COPY alembic/ ./alembic/
COPY alembic.ini . 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) # Create non-root user (Alpine uses adduser instead of useradd)
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
@@ -31,7 +60,7 @@ EXPOSE 8000
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 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 # Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,40 +0,0 @@
# Multi-stage build for Angular frontend
FROM node:24-alpine AS build
# Accept npm registry as build argument
ARG NPM_REGISTRY=https://registry.npmjs.org/
WORKDIR /app
# Copy package files
COPY frontend/package*.json ./
# Configure npm registry and regenerate package-lock.json if custom registry is provided
RUN if [ "$NPM_REGISTRY" != "https://registry.npmjs.org/" ]; then \
echo "Using custom npm registry: $NPM_REGISTRY"; \
npm config set registry "$NPM_REGISTRY"; \
rm -f package-lock.json; \
npm install --package-lock-only; \
fi
# Install dependencies
RUN npm ci
# Copy source code
COPY frontend/ ./
# Build for production
RUN npm run build:prod
# Final stage - nginx to serve static files
FROM nginx:alpine
# Copy built Angular app to nginx
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,15 +0,0 @@
# Dockerfile for pre-built Angular frontend (air-gapped/restricted environments)
# Build the Angular app locally first: cd frontend && npm run build:prod
# Then use this Dockerfile to package the pre-built files
FROM nginx:alpine
# Copy pre-built Angular app to nginx
COPY frontend/dist/frontend/browser /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -40,17 +40,12 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
**Linux/macOS:** **Linux/macOS:**
```bash ```bash
./quickstart.sh ./scripts/quickstart.sh
``` ```
**Windows (PowerShell):** **Windows (PowerShell):**
```powershell ```powershell
.\quickstart.ps1 .\scripts\quickstart.ps1
```
**Windows (Command Prompt):**
```batch
quickstart.bat
``` ```
### Manual Setup with Docker Compose ### Manual Setup with Docker Compose
@@ -274,6 +269,23 @@ Store compiled binaries, test data files, or any binary artifacts with full meta
## Development ## 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 ### Running Tests
```bash ```bash
pytest tests/ -v pytest tests/ -v
@@ -308,6 +320,26 @@ alembic upgrade head
- Verify `MINIO_ENDPOINT` is correct - Verify `MINIO_ENDPOINT` is correct
- Check MinIO credentials - 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 ## License
[Your License Here] [Your License Here]

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

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

View File

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

View File

@@ -4,10 +4,12 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.api.artifacts import router as artifacts_router from app.api.artifacts import router as artifacts_router
from app.api.seed import router as seed_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.database import init_db
from app.config import settings from app.config import settings
import logging import logging
import os import os
from pathlib import Path
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -19,8 +21,8 @@ logger = logging.getLogger(__name__)
# Create FastAPI app # Create FastAPI app
app = FastAPI( app = FastAPI(
title="Obsidian", title="Test Artifact Data Lake",
description="Enterprise Test Artifact Storage - API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures", description="API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
version="1.0.0", version="1.0.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc" redoc_url="/redoc"
@@ -38,11 +40,10 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(artifacts_router) app.include_router(artifacts_router)
app.include_router(seed_router) app.include_router(seed_router)
app.include_router(tags_router)
# Mount static files # Static files configuration - will be set up after routes
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") static_dir = Path("/app/static")
if os.path.exists(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.on_event("startup") @app.on_event("startup")
@@ -59,7 +60,7 @@ async def startup_event():
async def api_root(): async def api_root():
"""API root endpoint""" """API root endpoint"""
return { return {
"message": "Obsidian - Enterprise Test Artifact Storage", "message": "Test Artifact Data Lake API",
"version": "1.0.0", "version": "1.0.0",
"docs": "/docs", "docs": "/docs",
"deployment_mode": settings.deployment_mode, "deployment_mode": settings.deployment_mode,
@@ -68,17 +69,17 @@ async def api_root():
@app.get("/") @app.get("/")
async def ui_root(): async def serve_angular_app():
"""Serve the UI""" """Serve Angular app index.html"""
index_path = os.path.join(static_dir, "index.html") static_dir = Path("/app/static")
if os.path.exists(index_path): if static_dir.exists():
return FileResponse(index_path) return FileResponse(static_dir / "index.html")
else: else:
# Fallback if static files not found
return { return {
"message": "Obsidian - Enterprise Test Artifact Storage", "message": "Test Artifact Data Lake API",
"version": "1.0.0", "version": "1.0.0",
"docs": "/docs", "docs": "/docs",
"ui": "UI not found. Serving API only.",
"deployment_mode": settings.deployment_mode, "deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend "storage_backend": settings.storage_backend
} }
@@ -90,6 +91,31 @@ async def health_check():
return {"status": "healthy"} 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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(

View File

@@ -20,9 +20,7 @@ class Artifact(Base):
test_suite = Column(String(500), index=True) test_suite = Column(String(500), index=True)
test_config = Column(JSON) test_config = Column(JSON)
test_result = Column(String(50), index=True) # pass, fail, skip, error test_result = Column(String(50), index=True) # pass, fail, skip, error
sim_source_id = Column(String(500), index=True) # SIM source identifier for grouping
# SIM source grouping - allows multiple artifacts per source
sim_source_id = Column(String(100), index=True) # Groups artifacts from same SIM source
# Additional metadata # Additional metadata
custom_metadata = Column(JSON) custom_metadata = Column(JSON)

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

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

View File

@@ -8,7 +8,7 @@ class ArtifactCreate(BaseModel):
test_suite: Optional[str] = None test_suite: Optional[str] = None
test_config: Optional[Dict[str, Any]] = None test_config: Optional[Dict[str, Any]] = None
test_result: Optional[str] = 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 custom_metadata: Optional[Dict[str, Any]] = None
description: Optional[str] = None description: Optional[str] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None

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

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

156
dev-start.ps1 Normal file
View File

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

89
dev-start.sh Normal file
View File

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

View File

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

View File

@@ -34,7 +34,7 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
api: app:
build: . build: .
ports: ports:
- "8000:8000" - "8000:8000"
@@ -52,26 +52,11 @@ services:
minio: minio:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s interval: 10s
timeout: 10s timeout: 5s
retries: 3 retries: 5
start_period: 40s
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
args:
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/}
ports:
- "4200:80"
depends_on:
- api
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
volumes: volumes:
postgres_data: postgres_data:

View File

114
docs/FRONTEND_USAGE.md Normal file
View File

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

195
docs/QUICKSTART.md Normal file
View File

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

0
docs/todos.md Normal file
View File

1
frontend/.gitignore vendored
View File

@@ -36,7 +36,6 @@ yarn-error.log
/libpeerconnection.log /libpeerconnection.log
testem.log testem.log
/typings /typings
__screenshots__/
# System files # System files
.DS_Store .DS_Store

192
frontend/README-REGISTRY.md Normal file
View File

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

View File

@@ -1,178 +1,151 @@
# Obsidian Frontend - Angular Application # Test Artifact Data Lake - Angular Frontend
Modern Angular application for the Obsidian Test Artifact Data Lake. 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 ## Features
- **Angular 20** with standalone components **Multi-component Architecture**: Built with reusable Angular components
- **TypeScript** for type safety **Tab Navigation**: Clean tab-based interface for Artifacts, Upload, and Query
- **Reactive Forms** for upload and query functionality **Event ID Support**: Group multiple artifacts under the same event ID
- **RxJS** for reactive programming **Expandable Binaries Display**: Show first 4 binaries, expandable for more
- **Auto-refresh** artifacts every 5 seconds **Advanced Tag Management**: Create tags on-the-spot with database persistence
- **Client-side sorting and filtering** **Scoped Tags**: Organize tags by scope (project, environment, priority, etc.)
- **Dark theme** UI **Comprehensive Filtering**: Filter artifacts by all table criteria
- **Responsive design** **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 ## Development
### Prerequisites ### Prerequisites
- Node.js (v18 or later)
- Angular CLI 19
- Node.js 24.x or higher ### Setup
- npm 11.x or higher
- Backend API running on port 8000
### Installation
```bash ```bash
cd frontend cd frontend
npm install npm install
``` ```
### Run Development Server ### Development Server
```bash ```bash
npm start npm start
# or
ng serve
``` ```
The app will be available at `http://localhost:4200`
The application will be available at `http://localhost:4200/`
The development server includes a proxy configuration that forwards `/api` requests to `http://localhost:8000`.
### Build for Production ### Build for Production
```bash ```bash
npm run build:prod npm run build
# or
ng build
``` ```
Built files will be in `dist/frontend/`
Build artifacts will be in the `dist/frontend/browser` directory.
## Project Structure
```
src/
├── app/
│ ├── components/
│ │ ├── artifacts-list/ # Main artifacts table with sorting, filtering, auto-refresh
│ │ ├── upload-form/ # Reactive form for uploading artifacts
│ │ └── query-form/ # Advanced query interface
│ ├── models/
│ │ └── artifact.model.ts # TypeScript interfaces for type safety
│ ├── services/
│ │ └── artifact.ts # HTTP service for API calls
│ ├── app.ts # Main app component with routing
│ ├── app.config.ts # Application configuration
│ └── app.routes.ts # Route definitions
├── styles.css # Global dark theme styles
└── main.ts # Application bootstrap
## Key Components
### ArtifactsListComponent
- Displays artifacts in a sortable, filterable table
- Auto-refreshes every 5 seconds (toggleable)
- Client-side search across all fields
- Download and delete actions
- Detail modal for full artifact information
- Tags displayed as inline badges
- SIM source grouping support
### UploadFormComponent
- Reactive form with validation
- File upload with drag-and-drop support
- Required fields: File, Sim Source, Uploaded By, Tags
- Optional fields: SIM Source ID (for grouping), Test Result, Version, Description, Test Config, Custom Metadata
- JSON validation for config fields
- Real-time upload status feedback
### QueryFormComponent
- Advanced search with multiple filter criteria
- Filter by: filename, file type, test name, test suite, test result, SIM source ID, tags, date range
- Results emitted to artifacts list
## API Integration ## API Integration
The frontend communicates with the FastAPI backend through the `ArtifactService`: The frontend expects the backend API to be available at:
- Development: Same origin as the frontend
- Production: Configurable via environment files
- `GET /api/v1/artifacts/` - List all artifacts ### Required API Endpoints
- `GET /api/v1/artifacts/:id` - Get single artifact - `GET /api` - API information
- `POST /api/v1/artifacts/upload` - Upload new artifact - `GET /api/v1/artifacts/` - List artifacts
- `POST /api/v1/artifacts/query` - Query with filters - `GET /api/v1/artifacts/{id}` - Get artifact details
- `GET /api/v1/artifacts/:id/download` - Download artifact file - `POST /api/v1/artifacts/upload` - Upload artifact
- `DELETE /api/v1/artifacts/:id` - Delete artifact - `DELETE /api/v1/artifacts/{id}` - Delete artifact
- `POST /api/v1/seed/generate/:count` - Generate seed data - `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
## Configuration ## Key Features Implementation
### Proxy Configuration (`proxy.conf.json`) ### 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.
```json ### 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.
"/api": {
"target": "http://localhost:8000",
"secure": false,
"changeOrigin": true
}
}
```
This proxies all `/api` requests to the backend during development. ### 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
## Styling ### 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
The application uses a custom dark theme with: Filters are applied immediately as you type, and active filters are displayed as visual chips.
- Dark blue/slate color palette
- Gradient headers
- Responsive design
- Smooth transitions and hover effects
- Tag badges for categorization
- Result badges for test statuses
## Browser Support ## Architecture Improvements
- Chrome/Edge (latest) ### From Static to Angular
- Firefox (latest) The original static JavaScript implementation has been converted to:
- Safari (latest)
## Development Tips 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
1. **Hot Reload**: Changes to TypeScript files automatically trigger recompilation ### Benefits
2. **Type Safety**: Use TypeScript interfaces in `models/` for all API responses - **Maintainability**: Clear separation of concerns
3. **State Management**: Currently using component-level state; consider NgRx for complex state - **Reusability**: Components can be reused and extended
4. **Testing**: Run `npm test` for unit tests (Jasmine/Karma) - **Testing**: Angular's testing framework support
- **Performance**: Optimized change detection and lazy loading
- **Developer Experience**: Hot reload, TypeScript, and Angular DevTools
## Deployment ## Deployment
For production deployment, build the application and serve the `dist/frontend/browser` directory with your web server (nginx, Apache, etc.). ### Development
The Angular frontend can be served during development using `ng serve` and will proxy API calls to the backend.
Example nginx configuration: ### 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.
```nginx ### Integration with Existing Backend
server { The Angular frontend is designed to be a drop-in replacement for the static frontend. Simply:
listen 80;
server_name your-domain.com;
root /path/to/dist/frontend/browser;
location / { 1. Build the Angular app: `npm run build`
try_files $uri $uri/ /index.html; 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
location /api { ## Browser Support
proxy_pass http://backend:8000; - Chrome (latest)
} - Firefox (latest)
} - Safari (latest)
``` - Edge (latest)
## Future Enhancements ## Future Enhancements
- Drag and drop file upload
- [ ] Add NgRx for state management - Bulk operations
- [ ] Implement WebSocket for real-time updates - Advanced data visualization
- [ ] Add Angular Material components - Real-time updates via WebSocket
- [ ] Unit and E2E tests - Export functionality
- [ ] PWA support - User authentication integration
- [ ] Drag-and-drop file upload
- [ ] Bulk operations
- [ ] Export to CSV/JSON
## License
Same as parent project

View File

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

View File

@@ -1,17 +1,25 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"frontend": { "frontend": {
"projectType": "application", "projectType": "application",
"schematics": {}, "schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/frontend", "outputPath": "dist/frontend",
"index": "src/index.html", "index": "src/index.html",
@@ -20,6 +28,7 @@
"zone.js" "zone.js"
], ],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
@@ -27,8 +36,10 @@
} }
], ],
"styles": [ "styles": [
"src/styles.css" "@angular/material/prebuilt-themes/azure-blue.css",
] "src/styles.scss"
],
"scripts": []
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -55,7 +66,10 @@
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "frontend:build:production" "buildTarget": "frontend:build:production"
@@ -67,16 +81,17 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n" "builder": "@angular-devkit/build-angular:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"polyfills": [ "polyfills": [
"zone.js", "zone.js",
"zone.js/testing" "zone.js/testing"
], ],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
@@ -84,8 +99,10 @@
} }
], ],
"styles": [ "styles": [
"src/styles.css" "@angular/material/prebuilt-themes/azure-blue.css",
] "src/styles.scss"
],
"scripts": []
} }
} }
} }

View File

@@ -3,53 +3,53 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve",
"build": "ng build", "build": "ng build",
"build:prod": "ng build --configuration production",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
}, "registry:public": "node -e \"require('fs').copyFileSync('.npmrc.public', '.npmrc'); console.log('✓ Switched to public npm registry');\"",
"prettier": { "registry:artifactory": "node -e \"require('fs').copyFileSync('.npmrc.artifactory', '.npmrc'); console.log('✓ Switched to Artifactory registry');\""
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/common": "^19.0.0", "@angular/cdk": "^19.2.19",
"@angular/compiler": "^19.0.0", "@angular/common": "^19.2.0",
"@angular/core": "^19.0.0", "@angular/compiler": "^19.2.0",
"@angular/forms": "^19.0.0", "@angular/core": "^19.2.0",
"@angular/platform-browser": "^19.0.0", "@angular/forms": "^19.2.0",
"@angular/router": "^19.0.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", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^19.0.0", "@angular-devkit/build-angular": "^19.2.17",
"@angular/cli": "^19.0.0", "@angular/cli": "^19.2.17",
"@angular/compiler-cli": "^19.0.0", "@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0", "@types/node": "22.10.5",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.8.0" "typescript": "~5.7.2",
"vite": "6.3.6",
"rollup": "4.50.2",
"undici-types": "7.12.0"
}, },
"optionalDependencies": { "resolutions": {
"@esbuild/darwin-arm64": "^0.25.4", "vite": "6.3.6",
"@esbuild/darwin-x64": "^0.25.4", "rollup": "4.50.2",
"@esbuild/linux-arm64": "^0.25.4", "undici-types": "7.12.0"
"@esbuild/linux-x64": "^0.25.4" },
"overrides": {
"vite": "6.3.6",
"rollup": "4.50.2",
"undici-types": "7.12.0"
} }
} }

View File

@@ -2,6 +2,10 @@
"/api": { "/api": {
"target": "http://localhost:8000", "target": "http://localhost:8000",
"secure": false, "secure": false,
"changeOrigin": true "changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/api": "/api"
}
} }
} }

View File

@@ -0,0 +1,152 @@
<div class="app-container">
<!-- Material Toolbar Header -->
<mat-toolbar color="primary" class="app-toolbar">
<mat-icon class="app-icon">diamond</mat-icon>
<span class="app-title">◆ Obsidian</span>
<span class="spacer"></span>
<div class="header-info" *ngIf="apiInfo">
<mat-chip-set>
<mat-chip>
<mat-icon matChipAvatar>settings</mat-icon>
Mode: {{ apiInfo.deployment_mode }}
</mat-chip>
<mat-chip>
<mat-icon matChipAvatar>folder</mat-icon>
Storage: {{ apiInfo.storage_backend }}
</mat-chip>
</mat-chip-set>
</div>
</mat-toolbar>
<!-- Tab Navigation -->
<mat-tab-group [(selectedIndex)]="selectedTabIndex" class="main-tabs">
<!-- Artifacts Tab -->
<mat-tab label="Artifacts">
<ng-template matTabContent>
<div class="tab-content-wrapper">
<div class="toolbar">
<button mat-raised-button color="primary" (click)="loadArtifacts()">
<mat-icon>refresh</mat-icon>
Refresh
</button>
<button mat-raised-button [color]="autoRefreshEnabled ? 'accent' : ''" (click)="toggleAutoRefresh()">
Auto-refresh: {{ autoRefreshEnabled ? 'ON' : 'OFF' }}
</button>
<button mat-raised-button (click)="generateSeedData()">
<mat-icon>auto_awesome</mat-icon>
Generate Seed Data
</button>
<span class="count-badge">{{ artifacts.length }} artifacts</span>
<mat-form-field appearance="outline" class="filter-search">
<mat-label>Search</mat-label>
<input matInput [(ngModel)]="searchTerm" (input)="filterTable()" placeholder="Search...">
<mat-icon matPrefix>search</mat-icon>
<button mat-icon-button matSuffix *ngIf="searchTerm" (click)="clearSearch()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</div>
<table mat-table [dataSource]="filteredArtifacts" class="artifacts-table mat-elevation-z4">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th>
<td mat-cell *matCellDef="let artifact">{{ artifact.id }}</td>
</ng-container>
<!-- Filename Column -->
<ng-container matColumnDef="filename">
<th mat-header-cell *matHeaderCellDef>Filename</th>
<td mat-cell *matCellDef="let artifact" class="filename-cell">
<mat-icon class="file-icon">description</mat-icon>
{{ artifact.filename }}
</td>
</ng-container>
<!-- Type Column -->
<ng-container matColumnDef="file_type">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let artifact">
<mat-chip class="type-chip">{{ artifact.file_type }}</mat-chip>
</td>
</ng-container>
<!-- Size Column -->
<ng-container matColumnDef="file_size">
<th mat-header-cell *matHeaderCellDef>Size</th>
<td mat-cell *matCellDef="let artifact">{{ formatBytes(artifact.file_size) }}</td>
</ng-container>
<!-- Test Name Column -->
<ng-container matColumnDef="test_name">
<th mat-header-cell *matHeaderCellDef>Test Name</th>
<td mat-cell *matCellDef="let artifact">{{ artifact.test_name || '-' }}</td>
</ng-container>
<!-- Test Result Column -->
<ng-container matColumnDef="test_result">
<th mat-header-cell *matHeaderCellDef>Result</th>
<td mat-cell *matCellDef="let artifact">
<mat-chip *ngIf="artifact.test_result" [class]="'result-' + artifact.test_result">
{{ artifact.test_result }}
</mat-chip>
<span *ngIf="!artifact.test_result" class="text-muted">-</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let artifact">
<div class="action-buttons">
<button mat-icon-button (click)="downloadArtifact(artifact)" matTooltip="Download">
<mat-icon>download</mat-icon>
</button>
<button mat-icon-button (click)="deleteArtifact(artifact)" matTooltip="Delete" color="warn">
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</ng-template>
</mat-tab>
<!-- Upload Tab -->
<mat-tab label="Upload">
<ng-template matTabContent>
<div class="tab-content-wrapper">
<div class="content-header">
<h2>
<mat-icon>cloud_upload</mat-icon>
Upload Artifacts
</h2>
</div>
<app-upload-form (uploadSuccess)="onUploadSuccess()"></app-upload-form>
</div>
</ng-template>
</mat-tab>
<!-- Query Tab -->
<mat-tab label="Query">
<ng-template matTabContent>
<div class="tab-content-wrapper">
<div class="content-header">
<h2>
<mat-icon>search</mat-icon>
Query Artifacts
</h2>
</div>
<app-query-form
(queryResults)="onQueryResults($event)"
(filtersChange)="onFiltersChange($event)">
</app-query-form>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
</div>

View File

@@ -0,0 +1,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;
}
}
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'frontend' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
});
});

View File

@@ -0,0 +1,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];
}
}

View File

@@ -1,11 +1,3 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { ArtifactsListComponent } from './components/artifacts-list/artifacts-list';
import { UploadFormComponent } from './components/upload-form/upload-form';
import { QueryFormComponent } from './components/query-form/query-form';
export const routes: Routes = [ export const routes: Routes = [];
{ path: '', redirectTo: '/artifacts', pathMatch: 'full' },
{ path: 'artifacts', component: ArtifactsListComponent },
{ path: 'upload', component: UploadFormComponent },
{ path: 'query', component: QueryFormComponent }
];

View File

@@ -0,0 +1,283 @@
<div class="artifacts-section">
<!-- Debug Loading State -->
<div style="background: red; color: white; padding: 10px; margin: 10px;">
LOADING STATE: {{ loading ? 'TRUE (Loading...)' : 'FALSE (Not Loading)' }}
</div>
<!-- Toolbar -->
<mat-card class="toolbar-card">
<mat-card-content>
<div class="toolbar">
<div class="toolbar-buttons">
<button mat-raised-button color="primary" (click)="loadArtifacts()">
<mat-icon>refresh</mat-icon>
Refresh
</button>
<button mat-raised-button color="accent" (click)="generateSeedData()">
<mat-icon>scatter_plot</mat-icon>
Generate Seed Data
</button>
</div>
<mat-chip-set class="count-chip">
<mat-chip>
<mat-icon matChipAvatar>storage</mat-icon>
{{ filteredArtifacts.length }} artifacts
</mat-chip>
</mat-chip-set>
</div>
</mat-card-content>
</mat-card>
<!-- Loading Spinner -->
<div *ngIf="loading" class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading artifacts...</p>
</div>
<!-- Simple List Test (No Material Components) -->
<div *ngIf="!loading" style="background: lightgreen; padding: 20px; margin: 20px;">
<h3>Simple List Test</h3>
<p><strong>Filtered Artifacts Count:</strong> {{ filteredArtifacts.length }}</p>
<div *ngFor="let artifact of filteredArtifacts.slice(0, 5)" style="border: 1px solid black; padding: 10px; margin: 5px;">
<strong>ID:</strong> {{ artifact.id }} |
<strong>Filename:</strong> {{ artifact.filename }} |
<strong>Type:</strong> {{ artifact.file_type }}
</div>
</div>
<!-- Material Table -->
<mat-card *ngIf="!loading" class="table-card">
<div class="table-container">
<table mat-table [dataSource]="filteredArtifacts" class="artifacts-table">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th>
<td mat-cell *matCellDef="let artifact">
<strong>{{ artifact.id }}</strong>
</td>
</ng-container>
<!-- Event ID Column -->
<ng-container matColumnDef="eventId">
<th mat-header-cell *matHeaderCellDef>Event ID</th>
<td mat-cell *matCellDef="let artifact">
<mat-chip *ngIf="artifact.event_id" color="primary">
{{ artifact.event_id }}
</mat-chip>
<span *ngIf="!artifact.event_id" class="text-muted">{{ artifact.id }}</span>
</td>
</ng-container>
<!-- Filename Column -->
<ng-container matColumnDef="filename">
<th mat-header-cell *matHeaderCellDef>Filename</th>
<td mat-cell *matCellDef="let artifact">
<button mat-button (click)="showDetail(artifact)" class="filename-link">
<mat-icon>description</mat-icon>
{{ artifact.filename }}
</button>
</td>
</ng-container>
<!-- Type Column -->
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let artifact">
<mat-chip class="type-chip">{{ artifact.file_type }}</mat-chip>
</td>
</ng-container>
<!-- Size Column -->
<ng-container matColumnDef="size">
<th mat-header-cell *matHeaderCellDef>Size</th>
<td mat-cell *matCellDef="let artifact">{{ formatBytes(artifact.file_size) }}</td>
</ng-container>
<!-- Binaries Column -->
<ng-container matColumnDef="binaries">
<th mat-header-cell *matHeaderCellDef>Binaries</th>
<td mat-cell *matCellDef="let artifact" class="binaries-cell">
<div *ngIf="artifact.binaries && artifact.binaries.length > 0; else noBinaries">
<mat-chip-set>
<mat-chip *ngFor="let binary of getVisibleBinaries(artifact.binaries)" class="binary-chip">
<mat-icon matChipAvatar>code</mat-icon>
{{ binary }}
</mat-chip>
<mat-chip
*ngIf="getHiddenBinariesCount(artifact.binaries) > 0"
(click)="toggleBinariesExpansion(artifact.id)"
class="expand-chip">
<span *ngIf="!expandedBinaries[artifact.id]">
+{{ getHiddenBinariesCount(artifact.binaries) }} more
</span>
<span *ngIf="expandedBinaries[artifact.id]">- less</span>
</mat-chip>
</mat-chip-set>
<mat-chip-set *ngIf="expandedBinaries[artifact.id]" class="expanded-binaries">
<mat-chip *ngFor="let binary of artifact.binaries.slice(4)" class="binary-chip">
<mat-icon matChipAvatar>code</mat-icon>
{{ binary }}
</mat-chip>
</mat-chip-set>
</div>
<ng-template #noBinaries>
<span class="text-muted">-</span>
</ng-template>
</td>
</ng-container>
<!-- Test Name Column -->
<ng-container matColumnDef="testName">
<th mat-header-cell *matHeaderCellDef>Test Name</th>
<td mat-cell *matCellDef="let artifact">
<span>{{ artifact.test_name || '-' }}</span>
</td>
</ng-container>
<!-- Suite Column -->
<ng-container matColumnDef="suite">
<th mat-header-cell *matHeaderCellDef>Suite</th>
<td mat-cell *matCellDef="let artifact">
<span>{{ artifact.test_suite || '-' }}</span>
</td>
</ng-container>
<!-- Result Column -->
<ng-container matColumnDef="result">
<th mat-header-cell *matHeaderCellDef>Result</th>
<td mat-cell *matCellDef="let artifact">
<mat-chip
*ngIf="artifact.test_result"
[class]="'result-' + artifact.test_result">
<mat-icon matChipAvatar>{{ getResultIcon(artifact.test_result) }}</mat-icon>
{{ artifact.test_result }}
</mat-chip>
<span *ngIf="!artifact.test_result" class="text-muted">-</span>
</td>
</ng-container>
<!-- Tags Column -->
<ng-container matColumnDef="tags">
<th mat-header-cell *matHeaderCellDef>Tags</th>
<td mat-cell *matCellDef="let artifact" class="tags-cell">
<div *ngIf="artifact.tags && artifact.tags.length > 0; else noTags">
<mat-chip-set>
<mat-chip *ngFor="let tag of getVisibleTags(artifact.tags)" class="tag-chip">
{{ tag }}
</mat-chip>
<mat-chip
*ngIf="getHiddenTagsCount(artifact.tags) > 0"
(click)="toggleTagsExpansion(artifact.id)"
class="expand-chip">
<span *ngIf="!expandedTags[artifact.id]">
+{{ getHiddenTagsCount(artifact.tags) }} more
</span>
<span *ngIf="expandedTags[artifact.id]">- less</span>
</mat-chip>
</mat-chip-set>
<mat-chip-set *ngIf="expandedTags[artifact.id]" class="expanded-tags">
<mat-chip *ngFor="let tag of artifact.tags.slice(3)" class="tag-chip">
{{ tag }}
</mat-chip>
</mat-chip-set>
<app-tag-manager
[artifactId]="artifact.id"
[currentTags]="artifact.tags"
(tagsUpdated)="onTagsUpdated()">
</app-tag-manager>
</div>
<ng-template #noTags>
<app-tag-manager
[artifactId]="artifact.id"
[currentTags]="[]"
(tagsUpdated)="onTagsUpdated()">
</app-tag-manager>
</ng-template>
</td>
</ng-container>
<!-- Created Column -->
<ng-container matColumnDef="created">
<th mat-header-cell *matHeaderCellDef>Created</th>
<td mat-cell *matCellDef="let artifact">
<small>{{ formatDate(artifact.created_at) }}</small>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let artifact">
<div class="action-buttons">
<button mat-icon-button
(click)="downloadArtifact(artifact)"
matTooltip="Download"
color="primary">
<mat-icon>download</mat-icon>
</button>
<button mat-icon-button
(click)="deleteArtifact(artifact)"
matTooltip="Delete"
color="warn">
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<!-- Table Header and Rows -->
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<!-- No Data Row -->
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell no-data" [attr.colspan]="displayedColumns.length">
<div class="no-data-content">
<mat-icon>inbox</mat-icon>
<p>No artifacts found. Upload some files to get started!</p>
</div>
</td>
</tr>
</table>
</div>
</mat-card>
<!-- Pagination -->
<mat-card class="pagination-card" *ngIf="!loading">
<mat-card-content>
<div class="pagination">
<button mat-icon-button
(click)="previousPage()"
[disabled]="currentPage === 1"
matTooltip="Previous page">
<mat-icon>chevron_left</mat-icon>
</button>
<mat-chip>Page {{ currentPage }}</mat-chip>
<button mat-icon-button
(click)="nextPage()"
[disabled]="filteredArtifacts.length < pageSize"
matTooltip="Next page">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</mat-card-content>
</mat-card>
</div>
<!-- Artifact Detail Modal (placeholder for now) -->
<div *ngIf="showDetailModal && selectedArtifact" class="detail-backdrop" (click)="closeDetailModal()">
<mat-card class="detail-modal" (click)="$event.stopPropagation()">
<mat-card-header>
<mat-card-title>Artifact Details</mat-card-title>
<button mat-icon-button (click)="closeDetailModal()" class="close-button">
<mat-icon>close</mat-icon>
</button>
</mat-card-header>
<mat-card-content>
<!-- Detail content will be added later -->
<p>Artifact ID: {{ selectedArtifact.id }}</p>
<p>Filename: {{ selectedArtifact.filename }}</p>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,281 @@
.artifacts-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar-card {
margin-bottom: 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.toolbar-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.count-chip {
margin-left: auto;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
gap: 16px;
p {
color: #666;
margin: 0;
}
}
.table-card {
overflow: hidden;
}
.table-container {
overflow-x: auto;
max-height: 70vh;
}
.artifacts-table {
width: 100%;
.mat-mdc-cell,
.mat-mdc-header-cell {
padding: 12px 8px;
border-bottom: 1px solid #e0e0e0;
}
.mat-mdc-header-cell {
font-weight: 600;
background-color: #fafafa;
}
.mat-mdc-row:hover {
background-color: #f5f5f5;
}
}
// Column specific styles
.binaries-cell,
.tags-cell {
max-width: 250px;
mat-chip-set {
max-width: 100%;
}
}
.filename-link {
text-align: left;
justify-content: flex-start;
text-transform: none;
mat-icon {
margin-right: 8px;
}
}
.text-muted {
color: #999;
font-style: italic;
}
// Chip styles
.type-chip {
background-color: #e3f2fd !important;
color: #1976d2 !important;
font-size: 11px;
font-weight: 500;
}
.binary-chip {
background-color: #f3e5f5 !important;
color: #7b1fa2 !important;
font-size: 10px;
mat-icon {
font-size: 14px;
}
}
.tag-chip {
background-color: #e8f5e8 !important;
color: #2e7d32 !important;
font-size: 10px;
}
.expand-chip {
background-color: #fff3e0 !important;
color: #f57c00 !important;
font-size: 9px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #ffe0b2 !important;
}
}
.expanded-binaries,
.expanded-tags {
margin-top: 8px;
}
// Result chips
.result-pass {
background-color: #e8f5e8 !important;
color: #2e7d32 !important;
}
.result-fail {
background-color: #ffebee !important;
color: #d32f2f !important;
}
.result-skip {
background-color: #fff8e1 !important;
color: #f57c00 !important;
}
.result-error {
background-color: #fce4ec !important;
color: #c2185b !important;
}
// Action buttons
.action-buttons {
display: flex;
gap: 4px;
}
// No data state
.no-data {
text-align: center;
padding: 40px !important;
}
.no-data-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #666;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: #ccc;
}
p {
margin: 0;
font-size: 16px;
}
}
// Pagination
.pagination-card {
margin-top: 0;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
}
// Detail modal
.detail-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.detail-modal {
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
position: relative;
.close-button {
position: absolute;
top: 8px;
right: 8px;
}
}
// Responsive design
@media (max-width: 768px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-buttons {
justify-content: center;
}
.count-chip {
margin-left: 0;
align-self: center;
}
.artifacts-table {
font-size: 12px;
.mat-mdc-cell,
.mat-mdc-header-cell {
padding: 8px 4px;
}
}
.binaries-cell,
.tags-cell {
max-width: 150px;
}
.action-buttons {
flex-direction: column;
}
}
// Override Material styles
:host ::ng-deep {
.mat-mdc-table {
background: transparent;
}
.mat-mdc-chip {
--mdc-chip-container-height: 24px;
--mdc-chip-with-avatar-container-height: 28px;
font-size: 11px;
}
.mat-mdc-chip-set {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}

View File

@@ -0,0 +1,279 @@
import { Component, OnInit, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatCardModule } from '@angular/material/card';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialogModule } from '@angular/material/dialog';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Artifact } from '../../models/artifact.interface';
import { ArtifactService } from '../../services/artifact.service';
import { NotificationService } from '../../services/notification.service';
import { TagManagerComponent } from '../tag-manager/tag-manager.component';
@Component({
selector: 'app-artifacts-table',
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatCardModule,
MatProgressSpinnerModule,
MatTooltipModule,
MatDialogModule,
MatSnackBarModule,
TagManagerComponent
],
templateUrl: './artifacts-table.component.html',
styleUrl: './artifacts-table.component.scss'
})
export class ArtifactsTableComponent implements OnInit, OnChanges {
@Input() artifacts: Artifact[] = [];
@Input() filters: any = {};
displayedColumns: string[] = [
'id',
'eventId',
'filename',
'type',
'size',
'binaries',
'testName',
'suite',
'result',
'tags',
'created',
'actions'
];
expandedBinaries: { [key: number]: boolean } = {};
expandedTags: { [key: number]: boolean } = {};
currentPage = 1;
pageSize = 25;
loading = false; // Start with false to show content immediately
selectedArtifact: Artifact | null = null;
showDetailModal = false;
filteredArtifacts: Artifact[] = [];
constructor(
private artifactService: ArtifactService,
private notificationService: NotificationService
) {}
ngOnInit(): void {
console.log('ArtifactsTableComponent ngOnInit - artifacts count:', this.artifacts.length);
console.log('Initial loading state:', this.loading);
// Always load artifacts on init
this.loadArtifacts();
// Force show after a delay to debug
setTimeout(() => {
console.log('Timeout - forcing loading to false');
this.loading = false;
}, 2000);
}
ngOnChanges(changes: SimpleChanges): void {
console.log('ArtifactsTableComponent ngOnChanges - artifacts:', changes['artifacts']?.currentValue?.length || 0);
// Re-apply filters when artifacts or filters input changes
if (changes['artifacts'] || changes['filters']) {
this.applyFilters();
}
}
loadArtifacts(): void {
console.log('Loading artifacts...');
this.loading = true;
this.artifactService.getArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize)
.subscribe({
next: (artifacts) => {
console.log('Loaded artifacts:', artifacts.length);
this.artifacts = artifacts;
this.applyFilters();
this.loading = false;
console.log('Loading complete. loading =', this.loading);
},
error: (error) => {
console.error('Error loading artifacts:', error);
this.loading = false;
console.log('Error occurred. loading =', this.loading);
}
});
}
applyFilters(): void {
console.log('Applying filters to', this.artifacts.length, 'artifacts');
this.filteredArtifacts = this.artifacts.filter(artifact => {
if (this.filters.filename && !artifact.filename.toLowerCase().includes(this.filters.filename.toLowerCase())) {
return false;
}
if (this.filters.fileType && artifact.file_type !== this.filters.fileType) {
return false;
}
if (this.filters.testName && !artifact.test_name?.toLowerCase().includes(this.filters.testName.toLowerCase())) {
return false;
}
if (this.filters.testSuite && !artifact.test_suite?.toLowerCase().includes(this.filters.testSuite.toLowerCase())) {
return false;
}
if (this.filters.testResult && artifact.test_result !== this.filters.testResult) {
return false;
}
if (this.filters.tags && this.filters.tags.length > 0) {
const hasMatchingTag = this.filters.tags.some((tag: string) =>
artifact.tags.some(artifactTag => artifactTag.toLowerCase().includes(tag.toLowerCase()))
);
if (!hasMatchingTag) return false;
}
return true;
});
console.log('Filtered artifacts count:', this.filteredArtifacts.length);
}
toggleBinariesExpansion(artifactId: number): void {
this.expandedBinaries[artifactId] = !this.expandedBinaries[artifactId];
}
toggleTagsExpansion(artifactId: number): void {
this.expandedTags[artifactId] = !this.expandedTags[artifactId];
}
showDetail(artifact: Artifact): void {
this.selectedArtifact = artifact;
this.showDetailModal = true;
}
closeDetailModal(): void {
this.showDetailModal = false;
this.selectedArtifact = null;
}
downloadArtifact(artifact: Artifact): void {
this.artifactService.downloadArtifact(artifact.id).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = artifact.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
error: (error) => {
console.error('Error downloading artifact:', error);
this.notificationService.showError('Error downloading artifact: ' + error.message);
}
});
}
async deleteArtifact(artifact: Artifact): Promise<void> {
const confirmed = await this.notificationService.showConfirmation(
`Are you sure you want to delete "${artifact.filename}"? This cannot be undone.`,
'Delete'
);
if (!confirmed) {
return;
}
this.artifactService.deleteArtifact(artifact.id).subscribe({
next: () => {
this.notificationService.showSuccess('Artifact deleted successfully');
this.loadArtifacts();
},
error: (error) => {
console.error('Error deleting artifact:', error);
this.notificationService.showError('Error deleting artifact: ' + error.message);
}
});
}
generateSeedData(): void {
const count = prompt('How many artifacts to generate? (1-100)', '10');
if (!count) return;
const num = parseInt(count);
if (isNaN(num) || num < 1 || num > 100) {
this.notificationService.showWarning('Please enter a number between 1 and 100');
return;
}
this.artifactService.generateSeedData(num).subscribe({
next: (result) => {
this.notificationService.showSuccess(result.message || 'Seed data generated successfully');
this.loadArtifacts();
},
error: (error) => {
console.error('Error generating seed data:', error);
this.notificationService.showError('Error generating seed data: ' + error.message);
}
});
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString();
}
getVisibleBinaries(binaries: string[] | undefined): string[] {
if (!binaries) return [];
return binaries.slice(0, 4);
}
getHiddenBinariesCount(binaries: string[] | undefined): number {
if (!binaries) return 0;
return Math.max(0, binaries.length - 4);
}
getVisibleTags(tags: string[]): string[] {
return tags.slice(0, 3);
}
getHiddenTagsCount(tags: string[]): number {
return Math.max(0, tags.length - 3);
}
previousPage(): void {
if (this.currentPage > 1) {
this.currentPage--;
this.loadArtifacts();
}
}
nextPage(): void {
this.currentPage++;
this.loadArtifacts();
}
onTagsUpdated(): void {
this.loadArtifacts();
}
getResultIcon(result: string): string {
switch (result) {
case 'pass': return 'check_circle';
case 'fail': return 'cancel';
case 'skip': return 'skip_next';
case 'error': return 'error';
default: return 'help';
}
}
}

View File

@@ -0,0 +1,197 @@
<mat-card class="query-card">
<mat-card-header>
<mat-card-title>
<mat-icon>search</mat-icon>
Query Artifacts
</mat-card-title>
<mat-card-subtitle>
Search and filter your artifact collection
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form (ngSubmit)="queryArtifacts()" #queryFormRef="ngForm" class="query-form">
<!-- Basic Search Section -->
<div class="form-section">
<h3>Basic Search</h3>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Filename</mat-label>
<input
matInput
name="filename"
[(ngModel)]="queryForm.filename"
(input)="onFilterChange()"
placeholder="Search filename...">
<mat-icon matSuffix>description</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>File Type</mat-label>
<mat-select
name="file_type"
[(ngModel)]="queryForm.file_type"
(selectionChange)="onFilterChange()">
<mat-option value="">All Types</mat-option>
<mat-option *ngFor="let type of fileTypes" [value]="type">
{{ type.toUpperCase() }}
</mat-option>
</mat-select>
<mat-icon matSuffix>category</mat-icon>
</mat-form-field>
</div>
</div>
<!-- Test Information Section -->
<div class="form-section">
<h3>Test Information</h3>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Test Name</mat-label>
<input
matInput
name="test_name"
[(ngModel)]="queryForm.test_name"
(input)="onFilterChange()"
placeholder="Search test name...">
<mat-icon matSuffix>quiz</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Test Suite</mat-label>
<input
matInput
name="test_suite"
[(ngModel)]="queryForm.test_suite"
(input)="onFilterChange()"
placeholder="e.g., integration">
<mat-icon matSuffix>folder</mat-icon>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Test Result</mat-label>
<mat-select
name="test_result"
[(ngModel)]="queryForm.test_result"
(selectionChange)="onFilterChange()">
<mat-option value="">All Results</mat-option>
<mat-option *ngFor="let result of testResults" [value]="result">
<mat-icon>{{ getResultIcon(result) }}</mat-icon>
{{ result | titlecase }}
</mat-option>
</mat-select>
<mat-icon matSuffix>assignment_turned_in</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tags</mat-label>
<input
matInput
name="tags"
[(ngModel)]="tagsInput"
(input)="onFilterChange()"
placeholder="e.g., regression, smoke">
<mat-icon matSuffix>label</mat-icon>
<mat-hint>Comma-separated tags</mat-hint>
</mat-form-field>
</div>
</div>
<!-- Date Range Section -->
<div class="form-section">
<h3>Date Range</h3>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Start Date</mat-label>
<input
matInput
[matDatepicker]="startPicker"
name="start_date"
[(ngModel)]="queryForm.start_date">
<mat-datepicker-toggle matSuffix [for]="startPicker"></mat-datepicker-toggle>
<mat-datepicker #startPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>End Date</mat-label>
<input
matInput
[matDatepicker]="endPicker"
name="end_date"
[(ngModel)]="queryForm.end_date">
<mat-datepicker-toggle matSuffix [for]="endPicker"></mat-datepicker-toggle>
<mat-datepicker #endPicker></mat-datepicker>
</mat-form-field>
</div>
</div>
<!-- Search Progress -->
<div class="search-progress" *ngIf="searching">
<mat-spinner diameter="24"></mat-spinner>
<span>Searching artifacts...</span>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="searching"
class="search-button">
<mat-icon>{{ searching ? 'hourglass_empty' : 'search' }}</mat-icon>
{{ searching ? 'Searching...' : 'Search Artifacts' }}
</button>
<button
mat-button
type="button"
(click)="clearQuery()"
[disabled]="searching">
<mat-icon>clear</mat-icon>
Clear Filters
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<!-- Active Filters Display -->
<mat-card class="filters-card" *ngIf="queryForm.filename || queryForm.file_type || queryForm.test_name || queryForm.test_suite || queryForm.test_result || tagsInput">
<mat-card-header>
<mat-card-title>
<mat-icon>filter_list</mat-icon>
Active Filters
</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-chip-set class="filter-chips">
<mat-chip *ngIf="queryForm.filename" color="primary">
<mat-icon matChipAvatar>description</mat-icon>
Filename: {{ queryForm.filename }}
</mat-chip>
<mat-chip *ngIf="queryForm.file_type" color="accent">
<mat-icon matChipAvatar>category</mat-icon>
Type: {{ queryForm.file_type }}
</mat-chip>
<mat-chip *ngIf="queryForm.test_name" color="primary">
<mat-icon matChipAvatar>quiz</mat-icon>
Test: {{ queryForm.test_name }}
</mat-chip>
<mat-chip *ngIf="queryForm.test_suite" color="accent">
<mat-icon matChipAvatar>folder</mat-icon>
Suite: {{ queryForm.test_suite }}
</mat-chip>
<mat-chip *ngIf="queryForm.test_result" color="primary">
<mat-icon matChipAvatar>assignment_turned_in</mat-icon>
Result: {{ queryForm.test_result }}
</mat-chip>
<mat-chip *ngIf="tagsInput" color="accent">
<mat-icon matChipAvatar>label</mat-icon>
Tags: {{ tagsInput }}
</mat-chip>
</mat-chip-set>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,207 @@
.query-card {
max-width: 900px;
margin: 0 auto 24px;
}
.filters-card {
max-width: 900px;
margin: 0 auto;
}
.query-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-section {
padding: 16px 0;
h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 500;
color: #424242;
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
}
&:not(:last-child) {
border-bottom: 1px solid #e0e0e0;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.search-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
background-color: #f5f5f5;
border-radius: 8px;
color: #666;
mat-spinner {
margin: 0;
}
span {
font-size: 14px;
font-weight: 500;
}
}
.form-actions {
display: flex;
gap: 16px;
justify-content: center;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
.search-button {
padding: 12px 32px;
font-size: 16px;
mat-icon {
margin-right: 8px;
}
}
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
mat-chip {
--mdc-chip-container-height: 36px;
mat-icon[matChipAvatar] {
background-color: transparent !important;
color: currentColor !important;
}
}
}
// Material Design overrides
:host ::ng-deep {
.mat-mdc-form-field {
width: 100%;
.mat-mdc-form-field-hint {
font-size: 12px;
}
}
.mat-mdc-select-panel {
max-height: 250px;
}
.mat-mdc-option {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.mat-mdc-chip {
font-size: 12px;
}
.mat-mdc-raised-button {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
&:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
}
.mat-datepicker-toggle {
.mat-icon-button {
width: 32px;
height: 32px;
.mat-mdc-button-touch-target {
width: 32px;
height: 32px;
}
}
}
}
// Card title styling
:host ::ng-deep .mat-mdc-card-header {
.mat-mdc-card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 20px;
font-weight: 500;
}
.mat-mdc-card-subtitle {
margin-top: 4px;
color: #666;
}
}
// Responsive design
@media (max-width: 768px) {
.query-card,
.filters-card {
margin: 0 16px 16px;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
align-items: stretch;
.search-button {
width: 100%;
}
}
.filter-chips {
gap: 6px;
mat-chip {
--mdc-chip-container-height: 32px;
font-size: 11px;
}
}
}
@media (max-width: 480px) {
.query-card,
.filters-card {
margin: 0 8px 12px;
}
.form-section h3 {
font-size: 14px;
}
}

View File

@@ -0,0 +1,137 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { ArtifactQuery, Artifact } from '../../models/artifact.interface';
import { ArtifactService } from '../../services/artifact.service';
import { NotificationService } from '../../services/notification.service';
@Component({
selector: 'app-query-form',
imports: [
CommonModule,
FormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressSpinnerModule,
MatDatepickerModule,
MatNativeDateModule
],
templateUrl: './query-form.component.html',
styleUrl: './query-form.component.scss'
})
export class QueryFormComponent {
@Output() queryResults = new EventEmitter<Artifact[]>();
@Output() filtersChange = new EventEmitter<any>();
queryForm: ArtifactQuery = {
filename: '',
file_type: '',
test_name: '',
test_suite: '',
test_result: '',
tags: [],
start_date: '',
end_date: '',
limit: 100,
offset: 0
};
searching = false;
fileTypes = ['csv', 'json', 'binary', 'pcap'];
testResults = ['pass', 'fail', 'skip', 'error'];
tagsInput = '';
constructor(
private artifactService: ArtifactService,
private notificationService: NotificationService
) {}
queryArtifacts(): void {
this.searching = true;
const query: ArtifactQuery = { ...this.queryForm };
if (this.tagsInput) {
query.tags = this.tagsInput.split(',').map(t => t.trim()).filter(t => t);
}
if (query.start_date) {
query.start_date = new Date(query.start_date).toISOString();
}
if (query.end_date) {
query.end_date = new Date(query.end_date).toISOString();
}
this.artifactService.queryArtifacts(query).subscribe({
next: (artifacts) => {
this.queryResults.emit(artifacts);
this.searching = false;
},
error: (error) => {
console.error('Query failed:', error);
this.notificationService.showError('Query failed: ' + error.message);
this.searching = false;
}
});
}
clearQuery(): void {
this.queryForm = {
filename: '',
file_type: '',
test_name: '',
test_suite: '',
test_result: '',
tags: [],
start_date: '',
end_date: '',
limit: 100,
offset: 0
};
this.tagsInput = '';
this.emitFilters();
}
emitFilters(): void {
const filters = {
filename: this.queryForm.filename,
fileType: this.queryForm.file_type,
testName: this.queryForm.test_name,
testSuite: this.queryForm.test_suite,
testResult: this.queryForm.test_result,
tags: this.tagsInput ? this.tagsInput.split(',').map(t => t.trim()).filter(t => t) : []
};
this.filtersChange.emit(filters);
}
onFilterChange(): void {
this.emitFilters();
}
getResultIcon(result: string): string {
switch (result) {
case 'pass': return 'check_circle';
case 'fail': return 'cancel';
case 'skip': return 'skip_next';
case 'error': return 'error';
default: return 'help';
}
}
}

View File

@@ -0,0 +1,30 @@
<mat-tab-group
[(selectedIndex)]="selectedIndex"
(selectedTabChange)="onTabChange($event)"
animationDuration="300ms"
color="primary">
<!-- Artifacts Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">view_list</mat-icon>
Artifacts
</ng-template>
</mat-tab>
<!-- Upload Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">cloud_upload</mat-icon>
Upload
</ng-template>
</mat-tab>
<!-- Query Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">search</mat-icon>
Query
</ng-template>
</mat-tab>
</mat-tab-group>

View File

@@ -0,0 +1,44 @@
.tab-icon {
margin-right: 8px;
font-size: 18px;
vertical-align: middle;
}
:host ::ng-deep {
.mat-mdc-tab-group {
.mat-mdc-tab-header {
border-bottom: 1px solid #e0e0e0;
}
.mat-mdc-tab {
min-width: 120px;
.mat-mdc-tab-label {
display: flex;
align-items: center;
font-weight: 500;
}
}
.mat-mdc-tab-body-wrapper {
display: none;
}
}
}
@media (max-width: 768px) {
:host ::ng-deep {
.mat-mdc-tab {
min-width: 80px;
.mat-mdc-tab-label {
font-size: 12px;
}
}
}
.tab-icon {
font-size: 16px;
margin-right: 4px;
}
}

View File

@@ -0,0 +1,33 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconModule } from '@angular/material/icon';
export type TabType = 'artifacts' | 'upload' | 'query';
@Component({
selector: 'app-tab-navigation',
imports: [
CommonModule,
MatTabsModule,
MatIconModule
],
templateUrl: './tab-navigation.component.html',
styleUrl: './tab-navigation.component.scss'
})
export class TabNavigationComponent {
@Output() tabChange = new EventEmitter<TabType>();
selectedIndex = 0;
tabs = [
{ id: 'artifacts' as TabType, label: 'Artifacts', icon: 'view_list' },
{ id: 'upload' as TabType, label: 'Upload', icon: 'cloud_upload' },
{ id: 'query' as TabType, label: 'Query', icon: 'search' }
];
onTabChange(event: any): void {
const selectedTab = this.tabs[event.index];
this.tabChange.emit(selectedTab.id);
}
}

View File

@@ -0,0 +1,151 @@
<div class="tag-manager">
<!-- Current Tags Display -->
<div class="current-tags-section" *ngIf="currentTags.length > 0">
<mat-chip-set>
<mat-chip
*ngFor="let tag of currentTags"
class="current-tag"
[removable]="true"
(removed)="removeTag(tag)">
<mat-icon matChipAvatar>label</mat-icon>
{{ tag }}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
</mat-chip-set>
</div>
<!-- Add Tag Button -->
<div class="add-tag-section">
<button
mat-fab
color="primary"
class="add-tag-fab"
(click)="toggleAddTag()"
[matTooltip]="showAddTag ? 'Close tag manager' : 'Add new tag'">
<mat-icon>{{ showAddTag ? 'close' : 'add' }}</mat-icon>
</button>
</div>
<!-- Add Tag Form -->
<mat-card class="add-tag-card" *ngIf="showAddTag">
<mat-card-header>
<mat-card-title>
<mat-icon>new_label</mat-icon>
Add New Tag
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form class="tag-form">
<!-- Tag Name Input -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Tag Name</mat-label>
<input
matInput
[(ngModel)]="newTagName"
name="tagName"
placeholder="Enter tag name"
(keyup.enter)="addTag()">
<mat-icon matSuffix>label</mat-icon>
</mat-form-field>
<!-- Scope Section -->
<div class="scope-section">
<button
mat-button
color="accent"
type="button"
(click)="toggleScopeInput()">
<mat-icon>{{ showScopeInput ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ showScopeInput ? 'Hide Scope Options' : 'Add Scope' }}
</button>
<div class="scope-inputs" *ngIf="showScopeInput">
<mat-form-field appearance="outline">
<mat-label>Predefined Scope</mat-label>
<mat-select [(ngModel)]="newTagScope" name="scopeSelect">
<mat-option value="">No scope</mat-option>
<mat-option *ngFor="let scope of predefinedScopes" [value]="scope">
{{ scope | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Custom Scope</mat-label>
<input
matInput
[(ngModel)]="newTagScope"
name="customScope"
placeholder="Enter custom scope">
<mat-icon matSuffix>category</mat-icon>
</mat-form-field>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button
mat-raised-button
color="primary"
(click)="addTag()"
[disabled]="!newTagName.trim()">
<mat-icon>add</mat-icon>
Add Tag
</button>
<button
mat-button
(click)="resetForm()">
<mat-icon>clear</mat-icon>
Clear
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<!-- Available Tags (Quick Add) -->
<mat-expansion-panel class="available-tags-panel" *ngIf="showAddTag && availableTags.length > 0">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>library_add</mat-icon>
Quick Add Existing Tags
</mat-panel-title>
<mat-panel-description>
Click to add existing tags
</mat-panel-description>
</mat-expansion-panel-header>
<!-- Unscoped Tags -->
<div class="tag-group" *ngIf="getTagsByScope().length > 0">
<h4>General Tags</h4>
<mat-chip-set class="available-chip-set">
<mat-chip
*ngFor="let tag of getTagsByScope()"
class="available-tag"
[class.attached]="isTagAttached(tag.name)"
[disabled]="isTagAttached(tag.name)"
(click)="!isTagAttached(tag.name) && addExistingTag(tag)">
{{ tag.name }}
<mat-icon *ngIf="isTagAttached(tag.name)" matChipTrailingIcon>check</mat-icon>
</mat-chip>
</mat-chip-set>
</div>
<!-- Scoped Tags -->
<div class="tag-group" *ngFor="let scope of getUniqueScopes()">
<h4>{{ scope | titlecase }} Tags</h4>
<mat-chip-set class="available-chip-set">
<mat-chip
*ngFor="let tag of getTagsByScope(scope)"
class="available-tag scoped"
[class.attached]="isTagAttached(tag.name)"
[disabled]="isTagAttached(tag.name)"
(click)="!isTagAttached(tag.name) && addExistingTag(tag)">
{{ tag.name }}
<mat-icon *ngIf="isTagAttached(tag.name)" matChipTrailingIcon>check</mat-icon>
</mat-chip>
</mat-chip-set>
</div>
</mat-expansion-panel>
</div>

View File

@@ -0,0 +1,198 @@
.tag-manager {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.current-tags-section {
margin-bottom: 8px;
mat-chip-set {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.current-tag {
background-color: #e3f2fd !important;
color: #1976d2 !important;
mat-icon[matChipAvatar] {
background-color: #1976d2 !important;
color: white !important;
}
}
}
.add-tag-section {
display: flex;
justify-content: center;
margin: 16px 0;
.add-tag-fab {
width: 56px;
height: 56px;
}
}
.add-tag-card {
max-width: 500px;
margin: 0 auto;
mat-card-header {
margin-bottom: 16px;
mat-card-title {
display: flex;
align-items: center;
gap: 8px;
}
}
.tag-form {
display: flex;
flex-direction: column;
gap: 16px;
.full-width {
width: 100%;
}
.scope-section {
display: flex;
flex-direction: column;
gap: 12px;
.scope-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 16px;
}
}
}
.available-tags-panel {
margin-top: 16px;
.tag-group {
margin-bottom: 20px;
h4 {
font-size: 14px;
font-weight: 500;
color: #424242;
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
width: 4px;
height: 16px;
background-color: #2196f3;
border-radius: 2px;
}
}
.available-chip-set {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.available-tag {
cursor: pointer;
transition: all 0.2s ease;
&:not(.attached):hover {
background-color: #e8f5e8 !important;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
&.attached {
background-color: #c8e6c9 !important;
color: #2e7d32 !important;
cursor: default;
opacity: 0.7;
}
&.scoped {
border-left: 3px solid #ff9800;
}
}
}
}
// Material Design overrides
:host ::ng-deep {
.mat-mdc-chip {
--mdc-chip-container-height: 32px;
font-size: 12px;
&.current-tag {
--mdc-chip-with-avatar-container-height: 36px;
}
}
.mat-mdc-chip-set {
margin: 0;
}
.mat-mdc-fab {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
&:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.2);
}
}
.mat-expansion-panel-header {
font-weight: 500;
}
.mat-expansion-panel-body {
padding: 16px 24px 24px;
}
}
// Responsive design
@media (max-width: 768px) {
.tag-manager {
padding: 12px;
}
.add-tag-card {
margin: 0 8px;
.tag-form .scope-inputs {
grid-template-columns: 1fr;
}
}
.tag-group {
.available-chip-set {
gap: 6px;
}
}
}
@media (max-width: 480px) {
.add-tag-card .tag-form .form-actions {
flex-direction: column;
button {
width: 100%;
}
}
}

View File

@@ -0,0 +1,173 @@
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatChipsModule } from '@angular/material/chips';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatExpansionModule } from '@angular/material/expansion';
import { Tag } from '../../models/artifact.interface';
import { ArtifactService } from '../../services/artifact.service';
import { NotificationService } from '../../services/notification.service';
@Component({
selector: 'app-tag-manager',
imports: [
CommonModule,
FormsModule,
MatChipsModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatCardModule,
MatTooltipModule,
MatExpansionModule
],
templateUrl: './tag-manager.component.html',
styleUrl: './tag-manager.component.scss'
})
export class TagManagerComponent implements OnInit {
@Input() artifactId!: number;
@Input() currentTags: string[] = [];
@Output() tagsUpdated = new EventEmitter<void>();
availableTags: Tag[] = [];
newTagName = '';
newTagScope = '';
showAddTag = false;
showScopeInput = false;
predefinedScopes = ['project', 'environment', 'priority', 'category', 'status'];
constructor(
private artifactService: ArtifactService,
private notificationService: NotificationService
) {}
ngOnInit(): void {
this.loadAvailableTags();
}
loadAvailableTags(): void {
this.artifactService.getAllTags().subscribe({
next: (tags) => {
this.availableTags = tags;
},
error: (error) => {
// Tags endpoint not implemented yet - silently ignore
if (error.status === 404) {
console.log('Tags API not implemented yet');
this.availableTags = [];
} else {
console.error('Error loading tags:', error);
}
}
});
}
toggleAddTag(): void {
this.showAddTag = !this.showAddTag;
if (!this.showAddTag) {
this.resetForm();
}
}
toggleScopeInput(): void {
this.showScopeInput = !this.showScopeInput;
}
resetForm(): void {
this.newTagName = '';
this.newTagScope = '';
this.showScopeInput = false;
}
addTag(): void {
if (!this.newTagName.trim()) return;
const tag: Tag = {
name: this.newTagName.trim(),
scope: this.newTagScope.trim() || undefined
};
this.artifactService.createTag(tag).subscribe({
next: (createdTag) => {
this.artifactService.addTag(this.artifactId, createdTag).subscribe({
next: () => {
this.loadAvailableTags();
this.tagsUpdated.emit();
this.resetForm();
this.showAddTag = false;
},
error: (error) => {
console.error('Error adding tag to artifact:', error);
this.notificationService.showError('Error adding tag to artifact: ' + error.message);
}
});
},
error: (error) => {
console.error('Error creating tag:', error);
this.notificationService.showError('Error creating tag: ' + error.message);
}
});
}
removeTag(tag: string): void {
const tagToRemove = this.availableTags.find(t => t.name === tag);
if (!tagToRemove?.id) return;
this.artifactService.removeTag(this.artifactId, tagToRemove.id).subscribe({
next: () => {
this.tagsUpdated.emit();
},
error: (error) => {
console.error('Error removing tag:', error);
this.notificationService.showError('Error removing tag: ' + error.message);
}
});
}
addExistingTag(tag: Tag): void {
this.artifactService.addTag(this.artifactId, tag).subscribe({
next: () => {
this.tagsUpdated.emit();
},
error: (error) => {
console.error('Error adding existing tag:', error);
this.notificationService.showError('Error adding tag: ' + error.message);
}
});
}
isTagAttached(tagName: string): boolean {
return this.currentTags.includes(tagName);
}
getTagsByScope(scope?: string): Tag[] {
return this.availableTags.filter(tag => tag.scope === scope);
}
getUniqueScopes(): string[] {
const scopes = this.availableTags
.map(tag => tag.scope)
.filter((scope, index, arr) => scope && arr.indexOf(scope) === index) as string[];
return scopes.sort();
}
getTagColor(tag: Tag): string {
if (tag.color) return tag.color;
const colors = ['#e0e7ff', '#fef3c7', '#d1fae5', '#fee2e2', '#f3e8ff', '#dbeafe'];
const hash = tag.name.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0);
return a & a;
}, 0);
return colors[Math.abs(hash) % colors.length];
}
}

View File

@@ -0,0 +1,199 @@
<mat-card class="upload-card">
<mat-card-header>
<mat-card-title>
<mat-icon>cloud_upload</mat-icon>
Upload Artifact
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form (ngSubmit)="uploadArtifact()" #uploadForm="ngForm" class="upload-form">
<!-- File Upload Section -->
<div class="file-upload-section">
<div class="file-input-container">
<input
#fileInput
type="file"
id="file"
name="file"
(change)="onFileSelected($event)"
required
style="display: none;">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Select File</mat-label>
<input matInput
[value]="selectedFile?.name || ''"
placeholder="No file selected"
readonly>
<button mat-icon-button
matSuffix
type="button"
(click)="fileInput.click()"
[attr.aria-label]="'Select file'">
<mat-icon>folder_open</mat-icon>
</button>
<mat-hint>Supported: CSV, JSON, binary files, PCAP</mat-hint>
</mat-form-field>
</div>
<div *ngIf="selectedFile" class="selected-file-info">
<mat-chip-set>
<mat-chip color="primary">
<mat-icon matChipAvatar>description</mat-icon>
{{ selectedFile.name }}
</mat-chip>
<mat-chip color="accent">
<mat-icon matChipAvatar>data_usage</mat-icon>
{{ selectedFile.size | number }} bytes
</mat-chip>
</mat-chip-set>
</div>
</div>
<!-- Event ID and Test Name -->
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Event ID</mat-label>
<input matInput
name="eventId"
[(ngModel)]="formData.eventId"
placeholder="e.g., EVENT_001">
<mat-icon matSuffix>event</mat-icon>
<mat-hint>Groups multiple artifacts under the same event</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Test Name</mat-label>
<input matInput
name="testName"
[(ngModel)]="formData.testName"
placeholder="e.g., login_test">
<mat-icon matSuffix>quiz</mat-icon>
</mat-form-field>
</div>
<!-- Test Suite and Result -->
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Test Suite</mat-label>
<input matInput
name="testSuite"
[(ngModel)]="formData.testSuite"
placeholder="e.g., integration">
<mat-icon matSuffix>category</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Test Result</mat-label>
<mat-select name="testResult" [(ngModel)]="formData.testResult">
<mat-option value="">-- Select --</mat-option>
<mat-option *ngFor="let result of testResults" [value]="result">
<mat-icon>{{ getResultIcon(result) }}</mat-icon>
{{ result | titlecase }}
</mat-option>
</mat-select>
<mat-icon matSuffix>assignment_turned_in</mat-icon>
</mat-form-field>
</div>
<!-- Version and Binaries -->
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Version</mat-label>
<input matInput
name="version"
[(ngModel)]="formData.version"
placeholder="e.g., v1.0.0">
<mat-icon matSuffix>tag</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Associated Binaries</mat-label>
<input matInput
name="binaries"
[(ngModel)]="formData.binaries"
placeholder="e.g., app.exe, lib.dll, config.json">
<mat-icon matSuffix>code</mat-icon>
<mat-hint>Comma-separated list of binaries/files</mat-hint>
</mat-form-field>
</div>
<!-- Tags -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Tags</mat-label>
<input matInput
name="tags"
[(ngModel)]="formData.tags"
placeholder="e.g., regression, smoke, critical">
<mat-icon matSuffix>label</mat-icon>
<mat-hint>Comma-separated tags</mat-hint>
</mat-form-field>
<!-- Description -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea matInput
name="description"
[(ngModel)]="formData.description"
rows="3"
placeholder="Describe this artifact..."></textarea>
<mat-icon matSuffix>description</mat-icon>
</mat-form-field>
<!-- JSON Fields -->
<div class="json-section">
<h3>Advanced Configuration</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Test Config (JSON)</mat-label>
<textarea matInput
name="testConfig"
[(ngModel)]="formData.testConfig"
rows="4"
placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
<mat-icon matSuffix>settings</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Custom Metadata (JSON)</mat-label>
<textarea matInput
name="customMetadata"
[(ngModel)]="formData.customMetadata"
rows="4"
placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
<mat-icon matSuffix>data_object</mat-icon>
</mat-form-field>
</div>
<!-- Upload Progress -->
<mat-progress-bar *ngIf="uploading"
mode="indeterminate"
class="upload-progress"></mat-progress-bar>
<!-- Submit Button -->
<div class="form-actions">
<button mat-raised-button
color="primary"
type="submit"
[disabled]="uploading || !selectedFile"
class="upload-button">
<mat-icon>{{ uploading ? 'hourglass_empty' : 'cloud_upload' }}</mat-icon>
{{ uploading ? 'Uploading...' : 'Upload Artifact' }}
</button>
</div>
</form>
<!-- Status Messages -->
<div *ngIf="uploadStatus" class="status-section">
<mat-chip-set>
<mat-chip [class]="uploadStatusType">
<mat-icon matChipAvatar>
{{ uploadStatusType === 'success' ? 'check_circle' : 'error' }}
</mat-icon>
{{ uploadStatus }}
</mat-chip>
</mat-chip-set>
</div>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,124 @@
.upload-card {
max-width: 800px;
margin: 0 auto;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.file-upload-section {
margin-bottom: 24px;
.file-input-container {
position: relative;
}
}
.selected-file-info {
margin-top: 12px;
mat-chip-set {
gap: 8px;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.full-width {
width: 100%;
}
.json-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 500;
color: #424242;
}
}
.upload-progress {
margin: 16px 0;
}
.form-actions {
display: flex;
justify-content: center;
margin-top: 24px;
}
.upload-button {
padding: 12px 32px;
font-size: 16px;
mat-icon {
margin-right: 8px;
}
}
.status-section {
margin-top: 20px;
display: flex;
justify-content: center;
mat-chip-set {
justify-content: center;
}
.success {
background-color: #c8e6c9 !important;
color: #2e7d32 !important;
}
.error {
background-color: #ffcdd2 !important;
color: #d32f2f !important;
}
}
// Material Design overrides
:host ::ng-deep {
.mat-mdc-form-field {
width: 100%;
}
.mat-mdc-form-field-hint {
font-size: 12px;
}
.mat-mdc-chip {
--mdc-chip-container-height: 28px;
}
.mat-mdc-text-field-wrapper {
background-color: transparent;
}
}
// Responsive design
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.upload-card {
margin: 16px;
}
}
@media (max-width: 480px) {
.upload-button {
width: 100%;
}
}

View File

@@ -0,0 +1,176 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { ArtifactService } from '../../services/artifact.service';
@Component({
selector: 'app-upload-form',
imports: [
CommonModule,
FormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressBarModule,
MatSnackBarModule
],
templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss'
})
export class UploadFormComponent {
@Output() uploadSuccess = new EventEmitter<void>();
selectedFile: File | null = null;
uploading = false;
uploadStatus = '';
uploadStatusType: 'success' | 'error' | '' = '';
formData = {
testName: '',
testSuite: '',
testResult: '',
version: '',
description: '',
tags: '',
testConfig: '',
customMetadata: '',
eventId: '',
binaries: ''
};
testResults = ['pass', 'fail', 'skip', 'error'];
constructor(private artifactService: ArtifactService) {}
onFileSelected(event: any): void {
const file = event.target.files[0];
if (file) {
this.selectedFile = file;
}
}
resetForm(): void {
this.selectedFile = null;
this.formData = {
testName: '',
testSuite: '',
testResult: '',
version: '',
description: '',
tags: '',
testConfig: '',
customMetadata: '',
eventId: '',
binaries: ''
};
const fileInput = document.getElementById('file') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
showUploadStatus(message: string, success: boolean): void {
this.uploadStatus = message;
this.uploadStatusType = success ? 'success' : 'error';
setTimeout(() => {
this.uploadStatus = '';
this.uploadStatusType = '';
}, 5000);
}
uploadArtifact(): void {
if (!this.selectedFile) {
this.showUploadStatus('Please select a file to upload', false);
return;
}
this.uploading = true;
const formData = new FormData();
formData.append('file', this.selectedFile);
const fields = ['testName', 'testSuite', 'testResult', 'version', 'description', 'eventId'];
fields.forEach(field => {
const key = field === 'testName' ? 'test_name' :
field === 'testSuite' ? 'test_suite' :
field === 'testResult' ? 'test_result' :
field === 'eventId' ? 'event_id' : field;
const value = this.formData[field as keyof typeof this.formData];
if (value) {
formData.append(key, value);
}
});
if (this.formData.tags) {
const tagsArray = this.formData.tags.split(',').map(t => t.trim()).filter(t => t);
formData.append('tags', JSON.stringify(tagsArray));
}
if (this.formData.binaries) {
const binariesArray = this.formData.binaries.split(',').map(b => b.trim()).filter(b => b);
formData.append('binaries', JSON.stringify(binariesArray));
}
if (this.formData.testConfig) {
try {
JSON.parse(this.formData.testConfig);
formData.append('test_config', this.formData.testConfig);
} catch (e) {
this.showUploadStatus('Invalid Test Config JSON', false);
this.uploading = false;
return;
}
}
if (this.formData.customMetadata) {
try {
JSON.parse(this.formData.customMetadata);
formData.append('custom_metadata', this.formData.customMetadata);
} catch (e) {
this.showUploadStatus('Invalid Custom Metadata JSON', false);
this.uploading = false;
return;
}
}
this.artifactService.uploadArtifact(formData).subscribe({
next: (response) => {
this.showUploadStatus(`Successfully uploaded: ${response.filename}`, true);
this.resetForm();
this.uploadSuccess.emit();
this.uploading = false;
},
error: (error) => {
console.error('Upload error:', error);
this.showUploadStatus('Upload failed: ' + (error.error?.detail || error.message), false);
this.uploading = false;
}
});
}
getResultIcon(result: string): string {
switch (result) {
case 'pass': return 'check_circle';
case 'fail': return 'cancel';
case 'skip': return 'skip_next';
case 'error': return 'error';
default: return 'help';
}
}
}

View File

@@ -0,0 +1,51 @@
export interface Artifact {
id: number;
filename: string;
file_type: string;
file_size: number;
storage_path: string;
test_name?: string;
test_suite?: string;
test_result?: 'pass' | 'fail' | 'skip' | 'error';
test_config?: any;
custom_metadata?: any;
description?: string;
tags: string[];
version?: string;
created_at: string;
updated_at: string;
event_id?: string;
binaries?: string[];
}
export interface ArtifactQuery {
filename?: string;
file_type?: string;
test_name?: string;
test_suite?: string;
test_result?: string;
tags?: string[];
start_date?: string;
end_date?: string;
limit?: number;
offset?: number;
}
export interface ApiInfo {
deployment_mode: string;
storage_backend: string;
}
export interface UploadResponse {
id: number;
filename: string;
message: string;
}
export interface Tag {
id?: number;
name: string;
scope?: string;
color?: string;
created_at?: string;
}

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiInfo } from '../models/artifact.interface';
@Injectable({
providedIn: 'root'
})
export class ApiService {
// Use relative URL - proxy will forward to backend
private readonly API_BASE = '';
constructor(private http: HttpClient) { }
getApiInfo(): Observable<ApiInfo> {
return this.http.get<ApiInfo>(`${this.API_BASE}/api`);
}
}

View File

@@ -0,0 +1,72 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { Artifact, ArtifactQuery, UploadResponse, Tag } from '../models/artifact.interface';
@Injectable({
providedIn: 'root'
})
export class ArtifactService {
// Use relative URL - proxy will forward to backend
private readonly API_BASE = '/api/v1';
private artifactsSubject = new BehaviorSubject<Artifact[]>([]);
public artifacts$ = this.artifactsSubject.asObservable();
constructor(private http: HttpClient) { }
getArtifacts(limit: number = 1000, offset: number = 0): Observable<Artifact[]> {
const params = new HttpParams()
.set('limit', limit.toString())
.set('offset', offset.toString());
return this.http.get<Artifact[]>(`${this.API_BASE}/artifacts/`, { params });
}
getArtifact(id: number): Observable<Artifact> {
return this.http.get<Artifact>(`${this.API_BASE}/artifacts/${id}`);
}
uploadArtifact(formData: FormData): Observable<UploadResponse> {
return this.http.post<UploadResponse>(`${this.API_BASE}/artifacts/upload`, formData);
}
deleteArtifact(id: number): Observable<any> {
return this.http.delete(`${this.API_BASE}/artifacts/${id}`);
}
downloadArtifact(id: number): Observable<Blob> {
return this.http.get(`${this.API_BASE}/artifacts/${id}/download`, { responseType: 'blob' });
}
queryArtifacts(query: ArtifactQuery): Observable<Artifact[]> {
return this.http.post<Artifact[]>(`${this.API_BASE}/artifacts/query`, query);
}
generateSeedData(count: number): Observable<any> {
return this.http.post(`${this.API_BASE}/seed/generate/${count}`, {});
}
updateArtifactsCache(artifacts: Artifact[]): void {
this.artifactsSubject.next(artifacts);
}
getCurrentArtifacts(): Artifact[] {
return this.artifactsSubject.value;
}
addTag(artifactId: number, tag: Tag): Observable<any> {
return this.http.post(`${this.API_BASE}/artifacts/${artifactId}/tags`, tag);
}
removeTag(artifactId: number, tagId: number): Observable<any> {
return this.http.delete(`${this.API_BASE}/artifacts/${artifactId}/tags/${tagId}`);
}
getAllTags(): Observable<Tag[]> {
return this.http.get<Tag[]>(`${this.API_BASE}/tags`);
}
createTag(tag: Tag): Observable<Tag> {
return this.http.post<Tag>(`${this.API_BASE}/tags`, tag);
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root'
})
export class NotificationService {
constructor(private snackBar: MatSnackBar) {}
showSuccess(message: string, duration: number = 3000): void {
this.snackBar.open(message, 'Close', {
duration,
panelClass: ['success-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
}
showError(message: string, duration: number = 5000): void {
this.snackBar.open(message, 'Close', {
duration,
panelClass: ['error-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
}
showInfo(message: string, duration: number = 3000): void {
this.snackBar.open(message, 'Close', {
duration,
panelClass: ['info-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
}
showWarning(message: string, duration: number = 4000): void {
this.snackBar.open(message, 'Close', {
duration,
panelClass: ['warning-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
}
showConfirmation(message: string, action: string = 'Confirm'): Promise<boolean> {
const snackBarRef = this.snackBar.open(message, action, {
duration: 10000,
panelClass: ['confirmation-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
return new Promise((resolve) => {
snackBarRef.onAction().subscribe(() => resolve(true));
snackBarRef.afterDismissed().subscribe((info) => {
if (!info.dismissedByAction) {
resolve(false);
}
});
});
}
}

View File

@@ -6,6 +6,7 @@
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>
<body> <body>

View File

@@ -1,6 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { AppComponent } from './app/app'; import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig) bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err)); .catch((err) => console.error(err));

70
frontend/src/styles.scss Normal file
View File

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

View File

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

View File

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

View File

@@ -6,10 +6,10 @@
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": [ "files": [
"src/**/*.ts" "src/main.ts"
], ],
"exclude": [ "include": [
"src/**/*.spec.ts" "src/**/*.d.ts"
] ]
} }

View File

@@ -3,6 +3,7 @@
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true, "strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
@@ -10,25 +11,17 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, "skipLibCheck": true,
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "preserve" "module": "ES2022"
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true, "strictInjectionParameters": true,
"strictInputAccessModifiers": true, "strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true "strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
} }
]
} }

View File

@@ -9,6 +9,7 @@
] ]
}, },
"include": [ "include": [
"src/**/*.ts" "src/**/*.spec.ts",
"src/**/*.d.ts"
] ]
} }

View File

@@ -1,36 +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 { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
# Serve Angular app
location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# Angular routes - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Proxy API requests to backend # Proxy API requests to backend
location /api { location /api/ {
proxy_pass http://api:8000; proxy_pass http://api:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; 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_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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Cache static assets # Proxy redoc requests to backend
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location /redoc {
expires 1y; proxy_pass http://api:8000;
add_header Cache-Control "public, immutable"; proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy health check to backend
location /health {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
} }
} }

View File

@@ -1,106 +0,0 @@
@echo off
setlocal enabledelayedexpansion
echo =========================================
echo 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

View File

@@ -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 "=========================================" -ForegroundColor Cyan
Write-Host "Obsidian - Quick Start" -ForegroundColor Cyan Write-Host "Obsidian - Quick Start" -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "" 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 # Check if Docker is installed
try { if (-not (Get-Command "docker" -ErrorAction SilentlyContinue)) {
$dockerVersion = docker --version Write-Host "Error: Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
Write-Host "[OK] Docker found: $dockerVersion" -ForegroundColor Green Write-Host "Visit: https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
} catch {
Write-Host "[ERROR] Docker is not installed." -ForegroundColor Red
Write-Host "Please install Docker Desktop first:" -ForegroundColor Yellow
Write-Host "https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
Read-Host "Press Enter to exit" Read-Host "Press Enter to exit"
exit 1 exit 1
} }
# Determine Docker Compose command # Check if Docker Compose is available
$composeCmd = "docker-compose" $ComposeCmd = $null
if (Get-Command "docker-compose" -ErrorAction SilentlyContinue) {
$ComposeCmd = "docker-compose"
} else {
try { try {
docker-compose version | Out-Null & docker compose version | Out-Null
} catch { $ComposeCmd = "docker compose"
# Try new docker compose syntax }
try { catch {
docker compose version | Out-Null Write-Host "Error: Docker Compose is not available." -ForegroundColor Red
$composeCmd = "docker compose"
} catch {
Write-Host "[ERROR] Docker Compose is not available." -ForegroundColor Red
Write-Host "Please ensure Docker Desktop is running." -ForegroundColor Yellow Write-Host "Please ensure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit" Read-Host "Press Enter to exit"
exit 1 exit 1
} }
} }
Write-Host "[OK] Using: $composeCmd" -ForegroundColor Green
# Create .env file if it doesn't exist # 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 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 Write-Host "[OK] .env file created" -ForegroundColor Green
} else { } else {
Write-Host "[OK] .env file already exists" -ForegroundColor Green Write-Host "[OK] .env file already exists" -ForegroundColor Green
} }
Write-Host "" Write-Host ""
Write-Host "Building and starting services with Docker Compose..." -ForegroundColor Yellow
# Start services with rebuild # Handle rebuild logic
if ($composeCmd -eq "docker-compose") { if ($Rebuild) {
docker-compose up -d --build 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 { } else {
docker compose up -d --build & 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 {
Write-Host "Starting all services..." -ForegroundColor Green
if ($ComposeCmd -eq "docker compose") {
& docker compose up -d
} else {
& docker-compose up -d
} }
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "[ERROR] Failed to start services." -ForegroundColor Red
Write-Host "Make sure Docker Desktop is running." -ForegroundColor Yellow
Read-Host "Press Enter to exit"
exit 1
} }
Write-Host "" Write-Host ""
Write-Host "Waiting for services to be ready..." -ForegroundColor Yellow Write-Host "Waiting for services to be ready..." -ForegroundColor Yellow
Start-Sleep -Seconds 15 Start-Sleep -Seconds 20
Write-Host "" Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan 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 "=========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Write-Host "Web UI: " -NoNewline Write-Host "Application: http://localhost:8000" -ForegroundColor White
Write-Host "http://localhost:8000" -ForegroundColor Yellow Write-Host "API Docs: http://localhost:8000/docs" -ForegroundColor White
Write-Host "API Docs: " -NoNewline Write-Host "MinIO Console: http://localhost:9001" -ForegroundColor White
Write-Host "http://localhost:8000/docs" -ForegroundColor Yellow Write-Host " Username: minioadmin" -ForegroundColor Gray
Write-Host "MinIO Console: " -NoNewline Write-Host " Password: minioadmin" -ForegroundColor Gray
Write-Host "http://localhost:9001" -ForegroundColor Yellow
Write-Host " Username: minioadmin"
Write-Host " Password: minioadmin"
Write-Host "" Write-Host ""
Write-Host "To view logs: $composeCmd logs -f" -ForegroundColor Cyan Write-Host "To view logs: $ComposeCmd logs -f" -ForegroundColor Yellow
Write-Host "To stop: $composeCmd down" -ForegroundColor Cyan Write-Host "To stop: $ComposeCmd down" -ForegroundColor Yellow
Write-Host "" Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Testing the API..." -ForegroundColor Yellow Write-Host "Testing the API..." -ForegroundColor Cyan
Write-Host "=========================================" -ForegroundColor Cyan Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
# Wait a bit more for API # Wait a bit more for API to be fully ready
Start-Sleep -Seconds 5 Start-Sleep -Seconds 5
# Test health endpoint # Test health endpoint
try { try {
$response = Invoke-WebRequest -Uri "http://localhost:8000/health" -UseBasicParsing -TimeoutSec 5 $response = Invoke-RestMethod -Uri "http://localhost:8000/health" -Method Get -TimeoutSec 10
if ($response.Content -like "*healthy*") { if ($response.status -eq "healthy") {
Write-Host "[OK] API is healthy!" -ForegroundColor Green Write-Host "[OK] API is healthy!" -ForegroundColor Green
Write-Host "" Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan Write-Host "All services are ready!" -ForegroundColor Green
Write-Host "Opening browser..." -ForegroundColor Yellow Write-Host ""
Write-Host "http://localhost:8000" -ForegroundColor Yellow Write-Host "Example: Upload a test file" -ForegroundColor White
Write-Host "=========================================" -ForegroundColor Cyan Write-Host "----------------------------" -ForegroundColor Gray
Write-Host 'echo "test,data" > test.csv' -ForegroundColor Green
# Open browser Write-Host 'curl -X POST "http://localhost:8000/api/v1/artifacts/upload"' -ForegroundColor Green
Start-Process "http://localhost:8000" 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 catch {
Write-Host "Please wait a moment and check http://localhost:8000" -ForegroundColor Yellow Write-Host "[WARNING] API is not responding yet. Please wait a moment and check http://localhost:8000/health" -ForegroundColor Yellow
} }
Write-Host "" Write-Host ""
Write-Host "=========================================" -ForegroundColor Cyan Write-Host "=========================================" -ForegroundColor Cyan
Write-Host "Setup complete! " -NoNewline Write-Host "Setup complete!" -ForegroundColor Green
Write-Host "🚀" -ForegroundColor Green Write-Host "Open http://localhost:8000 in your browser" -ForegroundColor Yellow
Write-Host "=========================================" -ForegroundColor Cyan 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 ""

84
quickstart.sh Executable file → Normal file
View File

@@ -14,11 +14,64 @@ if ! command -v docker &> /dev/null; then
fi fi
# Check if Docker Compose is installed # 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." echo "Error: Docker Compose is not installed. Please install Docker Compose first."
exit 1 exit 1
fi 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 # Create .env file if it doesn't exist
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "Creating .env file from .env.example..." echo "Creating .env file from .env.example..."
@@ -29,26 +82,38 @@ else
fi fi
echo "" 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 ""
echo "Waiting for services to be ready..." echo "Waiting for services to be ready..."
sleep 10 sleep 20
echo "" echo ""
echo "=========================================" echo "========================================="
echo "Services are running!" echo "Complete Stack is running! 🚀"
echo "=========================================" echo "========================================="
echo "" echo ""
echo "API: http://localhost:8000" echo "Application: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs" echo "API Docs: http://localhost:8000/docs"
echo "MinIO Console: http://localhost:9001" echo "MinIO Console: http://localhost:9001"
echo " Username: minioadmin" echo " Username: minioadmin"
echo " Password: minioadmin" echo " Password: minioadmin"
echo "" echo ""
echo "To view logs: docker-compose logs -f" echo "To view logs: $COMPOSE_CMD logs -f"
echo "To stop: docker-compose down" echo "To stop: $COMPOSE_CMD down"
echo "" echo ""
echo "=========================================" echo "========================================="
echo "Testing the API..." echo "Testing the API..."
@@ -62,6 +127,8 @@ sleep 5
if curl -s http://localhost:8000/health | grep -q "healthy"; then if curl -s http://localhost:8000/health | grep -q "healthy"; then
echo "✓ API is healthy!" echo "✓ API is healthy!"
echo "" echo ""
echo "🎯 All services are ready!"
echo ""
echo "Example: Upload a test file" echo "Example: Upload a test file"
echo "----------------------------" echo "----------------------------"
echo 'echo "test,data" > test.csv' echo 'echo "test,data" > test.csv'
@@ -77,4 +144,5 @@ fi
echo "=========================================" echo "========================================="
echo "Setup complete! 🚀" echo "Setup complete! 🚀"
echo "Open http://localhost:8000 in your browser"
echo "=========================================" echo "========================================="

105
scripts/quickstart-build.sh Normal file
View File

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

View File

@@ -1,549 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f172a;
min-height: 100vh;
padding: 20px;
color: #e2e8f0;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: #1e293b;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
header {
background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%);
color: white;
padding: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 28px;
font-weight: 600;
}
.header-info {
display: flex;
gap: 10px;
}
.badge {
background: rgba(255, 255, 255, 0.2);
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
backdrop-filter: blur(10px);
}
.tabs {
display: flex;
background: #0f172a;
border-bottom: 2px solid #334155;
}
.tab-button {
flex: 1;
padding: 16px 24px;
background: none;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.tab-button:hover {
background: #1e293b;
color: #e2e8f0;
}
.tab-button.active {
background: #1e293b;
color: #60a5fa;
border-bottom: 3px solid #60a5fa;
}
.tab-content {
display: none;
padding: 30px;
}
.tab-content.active {
display: block;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
}
.filter-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #0f172a;
border-radius: 6px;
border: 1px solid #334155;
min-width: 250px;
}
.filter-inline input {
flex: 1;
padding: 4px 8px;
background: transparent;
border: none;
color: #e2e8f0;
font-size: 14px;
}
.filter-inline input:focus {
outline: none;
}
.filter-inline input::placeholder {
color: #64748b;
}
.btn-clear {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.btn-clear:hover {
background: #334155;
color: #e2e8f0;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
background: #334155;
color: #e2e8f0;
}
.btn-secondary:hover {
background: #475569;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-large {
padding: 14px 28px;
font-size: 16px;
}
.count-badge {
background: #1e3a8a;
color: #93c5fd;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
margin-left: auto;
}
.table-container {
overflow-x: auto;
border: 1px solid #334155;
border-radius: 8px;
background: #0f172a;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
thead {
background: #1e293b;
}
th {
padding: 14px 12px;
text-align: left;
font-weight: 600;
color: #94a3b8;
border-bottom: 2px solid #334155;
white-space: nowrap;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.5px;
}
th.sortable {
cursor: pointer;
user-select: none;
transition: color 0.3s;
}
th.sortable:hover {
color: #60a5fa;
}
.sort-indicator {
display: inline-block;
margin-left: 5px;
font-size: 10px;
color: #64748b;
}
th.sort-asc .sort-indicator::after {
content: '▲';
color: #60a5fa;
}
th.sort-desc .sort-indicator::after {
content: '▼';
color: #60a5fa;
}
td {
padding: 16px 12px;
border-bottom: 1px solid #1e293b;
color: #cbd5e1;
}
tbody tr:hover {
background: #1e293b;
}
.loading {
text-align: center;
color: #64748b;
padding: 40px !important;
}
.result-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.result-pass {
background: #064e3b;
color: #6ee7b7;
}
.result-fail {
background: #7f1d1d;
color: #fca5a5;
}
.result-skip {
background: #78350f;
color: #fcd34d;
}
.result-error {
background: #7f1d1d;
color: #fca5a5;
}
.tag {
display: inline-block;
background: #1e3a8a;
color: #93c5fd;
padding: 3px 8px;
border-radius: 10px;
font-size: 11px;
margin: 2px;
}
.file-type-badge {
background: #1e3a8a;
color: #93c5fd;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 20px;
padding: 20px;
}
#page-info {
font-weight: 500;
color: #94a3b8;
}
.upload-section, .query-section {
max-width: 800px;
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
label {
display: block;
font-weight: 500;
color: #cbd5e1;
margin-bottom: 6px;
font-size: 14px;
}
input[type="text"],
input[type="file"],
input[type="datetime-local"],
select,
textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #334155;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.3s;
background: #0f172a;
color: #e2e8f0;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
small {
color: #64748b;
font-size: 12px;
display: block;
margin-top: 4px;
}
#upload-status {
margin-top: 20px;
padding: 14px;
border-radius: 6px;
display: none;
}
#upload-status.success {
background: #064e3b;
color: #6ee7b7;
display: block;
}
#upload-status.error {
background: #7f1d1d;
color: #fca5a5;
display: block;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: #1e293b;
padding: 30px;
border-radius: 12px;
max-width: 700px;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border: 1px solid #334155;
}
.close {
position: absolute;
right: 20px;
top: 20px;
font-size: 28px;
font-weight: bold;
color: #64748b;
cursor: pointer;
transition: color 0.3s;
}
.close:hover {
color: #e2e8f0;
}
.detail-row {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #334155;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-weight: 600;
color: #94a3b8;
margin-bottom: 4px;
}
.detail-value {
color: #cbd5e1;
}
pre {
background: #0f172a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
border: 1px solid #334155;
}
code {
background: #0f172a;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: #93c5fd;
}
.action-buttons {
display: flex;
gap: 8px;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.3s;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: #334155;
color: #e2e8f0;
transform: scale(1.1);
}
/* Ensure SVG icons inherit color */
.icon-btn svg {
stroke: currentColor;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.table-container {
font-size: 12px;
}
th, td {
padding: 8px 6px;
}
.toolbar {
flex-wrap: wrap;
}
}

View File

@@ -1,251 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Obsidian - Test Artifact Data Lake</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div class="container">
<header>
<h1>◆ Obsidian</h1>
<div class="header-info">
<span id="deployment-mode" class="badge"></span>
<span id="storage-backend" class="badge"></span>
</div>
</header>
<nav class="tabs">
<button class="tab-button active" onclick="showTab('artifacts')">
<i data-lucide="database" style="width: 16px; height: 16px;"></i> Artifacts
</button>
<button class="tab-button" onclick="showTab('upload')">
<i data-lucide="upload" style="width: 16px; height: 16px;"></i> Upload
</button>
<button class="tab-button" onclick="showTab('query')">
<i data-lucide="search" style="width: 16px; height: 16px;"></i> Query
</button>
</nav>
<!-- Artifacts Tab -->
<div id="artifacts-tab" class="tab-content active">
<div class="toolbar">
<button onclick="loadArtifacts()" class="btn btn-primary">
<i data-lucide="refresh-cw" style="width: 16px; height: 16px;"></i> Refresh
</button>
<button id="auto-refresh-toggle" onclick="toggleAutoRefresh()" class="btn btn-success">
Auto-refresh: ON
</button>
<button onclick="generateSeedData()" class="btn btn-secondary">
<i data-lucide="sparkles" style="width: 16px; height: 16px;"></i> Generate Seed Data
</button>
<span id="artifact-count" class="count-badge"></span>
<div class="filter-inline">
<i data-lucide="search" style="width: 16px; height: 16px; color: #64748b;"></i>
<input type="text" id="filter-search" placeholder="Search..." oninput="filterTable()">
<button onclick="clearFilters()" class="btn-clear" title="Clear search">
<i data-lucide="x" style="width: 14px; height: 14px;"></i>
</button>
</div>
</div>
<div class="table-container">
<table id="artifacts-table">
<thead>
<tr>
<th class="sortable" onclick="sortTable('test_suite')">
Sim Source <span class="sort-indicator"></span>
</th>
<th class="sortable" onclick="sortTable('filename')">
Artifacts <span class="sort-indicator"></span>
</th>
<th class="sortable" onclick="sortTable('created_at')">
Date <span class="sort-indicator"></span>
</th>
<th class="sortable" onclick="sortTable('test_name')">
Uploaded By <span class="sort-indicator"></span>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="artifacts-tbody">
<tr>
<td colspan="5" class="loading">Loading artifacts...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button onclick="previousPage()" id="prev-btn" class="btn">← Previous</button>
<span id="page-info">Page 1</span>
<button onclick="nextPage()" id="next-btn" class="btn">Next →</button>
</div>
</div>
<!-- Upload Tab -->
<div id="upload-tab" class="tab-content">
<div class="upload-section">
<h2>Upload Artifact</h2>
<form id="upload-form" onsubmit="uploadArtifact(event)">
<div class="form-group">
<label for="file">File *</label>
<input type="file" id="file" name="file" required>
<small>Supported: CSV, JSON, binary files, PCAP</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="sim-source">Sim Source *</label>
<input type="text" id="sim-source" name="test_suite" placeholder="e.g., Jenkins, GitLab CI" required>
</div>
<div class="form-group">
<label for="uploaded-by">Uploaded By *</label>
<input type="text" id="uploaded-by" name="test_name" placeholder="e.g., john.doe" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sim-source-id">SIM Source ID (for grouping)</label>
<input type="text" id="sim-source-id" name="sim_source_id" placeholder="e.g., sim_run_20251015_001">
<small>Use same ID for multiple artifacts from same source</small>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated) *</label>
<input type="text" id="tags" name="tags" placeholder="e.g., regression, smoke, critical" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="test-result">Test Result</label>
<select id="test-result" name="test_result">
<option value="">-- Select --</option>
<option value="pass">Pass</option>
<option value="fail">Fail</option>
<option value="skip">Skip</option>
<option value="error">Error</option>
</select>
</div>
<div class="form-group">
<label for="version">Version</label>
<input type="text" id="version" name="version" placeholder="e.g., v1.0.0">
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="3" placeholder="Describe this artifact..."></textarea>
</div>
<div class="form-group">
<label for="test-config">Test Config (JSON)</label>
<textarea id="test-config" name="test_config" rows="4" placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
</div>
<div class="form-group">
<label for="custom-metadata">Custom Metadata (JSON)</label>
<textarea id="custom-metadata" name="custom_metadata" rows="4" placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
</div>
<button type="submit" class="btn btn-primary btn-large">
<i data-lucide="upload" style="width: 18px; height: 18px;"></i> Upload Artifact
</button>
</form>
<div id="upload-status"></div>
</div>
</div>
<!-- Query Tab -->
<div id="query-tab" class="tab-content">
<div class="query-section">
<h2>Query Artifacts</h2>
<form id="query-form" onsubmit="queryArtifacts(event)">
<div class="form-row">
<div class="form-group">
<label for="q-filename">Filename</label>
<input type="text" id="q-filename" placeholder="Search filename...">
</div>
<div class="form-group">
<label for="q-type">File Type</label>
<select id="q-type">
<option value="">All</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="binary">Binary</option>
<option value="pcap">PCAP</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-test-name">Test Name</label>
<input type="text" id="q-test-name" placeholder="Search test name...">
</div>
<div class="form-group">
<label for="q-suite">Test Suite</label>
<input type="text" id="q-suite" placeholder="e.g., integration">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-result">Test Result</label>
<select id="q-result">
<option value="">All</option>
<option value="pass">Pass</option>
<option value="fail">Fail</option>
<option value="skip">Skip</option>
<option value="error">Error</option>
</select>
</div>
<div class="form-group">
<label for="q-tags">Tags (comma-separated)</label>
<input type="text" id="q-tags" placeholder="e.g., regression, smoke">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-start-date">Start Date</label>
<input type="datetime-local" id="q-start-date">
</div>
<div class="form-group">
<label for="q-end-date">End Date</label>
<input type="datetime-local" id="q-end-date">
</div>
</div>
<button type="submit" class="btn btn-primary btn-large">
<i data-lucide="search" style="width: 18px; height: 18px;"></i> Search
</button>
<button type="button" onclick="clearQuery()" class="btn btn-secondary">
<i data-lucide="x" style="width: 18px; height: 18px;"></i> Clear
</button>
</form>
</div>
</div>
<!-- Artifact Detail Modal -->
<div id="detail-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeDetailModal()">&times;</span>
<h2>Artifact Details</h2>
<div id="detail-content"></div>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
<script>
// Initialize Lucide icons
lucide.createIcons();
</script>
</body>
</html>

View File

@@ -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 = `
<tr><td colspan="5" class="loading" style="color: #ef4444;">
Error loading artifacts: ${error.message}
</td></tr>
`;
}
}
// Display artifacts in table
function displayArtifacts(artifacts) {
const tbody = document.getElementById('artifacts-tbody');
if (artifacts.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="loading">No artifacts found. Upload some files to get started!</td></tr>';
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 => `
<tr>
<td>${artifact.sim_source_id || artifact.test_suite || '-'}</td>
<td>
<a href="#" onclick="showDetail(${artifact.id}); return false;" style="color: #60a5fa; text-decoration: none;">
${escapeHtml(artifact.filename)}
</a>
${artifact.tags && artifact.tags.length > 0 ? `<br><div style="margin-top: 5px;">${formatTags(artifact.tags)}</div>` : ''}
</td>
<td>${formatDate(artifact.created_at)}</td>
<td>${artifact.test_name || '-'}</td>
<td>
<div class="action-buttons">
<button class="icon-btn" onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" title="Download">
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
</button>
<button class="icon-btn" onclick="deleteArtifact(${artifact.id})" title="Delete">
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i>
</button>
</div>
</td>
</tr>
`).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 `<span class="${className}">${result}</span>`;
}
// Format tags
function formatTags(tags) {
if (!tags || tags.length === 0) return '-';
return tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join(' ');
}
// Format bytes
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Format date
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString();
}
// Escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Show artifact detail
async function showDetail(id) {
try {
const response = await fetch(`${API_BASE}/artifacts/${id}`);
const artifact = await response.json();
const detailContent = document.getElementById('detail-content');
detailContent.innerHTML = `
<div class="detail-row">
<div class="detail-label">ID</div>
<div class="detail-value">${artifact.id}</div>
</div>
<div class="detail-row">
<div class="detail-label">Filename</div>
<div class="detail-value">${escapeHtml(artifact.filename)}</div>
</div>
<div class="detail-row">
<div class="detail-label">File Type</div>
<div class="detail-value"><span class="file-type-badge">${artifact.file_type}</span></div>
</div>
<div class="detail-row">
<div class="detail-label">Size</div>
<div class="detail-value">${formatBytes(artifact.file_size)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Storage Path</div>
<div class="detail-value"><code>${artifact.storage_path}</code></div>
</div>
<div class="detail-row">
<div class="detail-label">Uploaded By</div>
<div class="detail-value">${artifact.test_name || '-'}</div>
</div>
<div class="detail-row">
<div class="detail-label">Sim Source</div>
<div class="detail-value">${artifact.test_suite || '-'}</div>
</div>
<div class="detail-row">
<div class="detail-label">Test Result</div>
<div class="detail-value">${formatResult(artifact.test_result)}</div>
</div>
${artifact.test_config ? `
<div class="detail-row">
<div class="detail-label">Test Config</div>
<div class="detail-value"><pre>${JSON.stringify(artifact.test_config, null, 2)}</pre></div>
</div>
` : ''}
${artifact.custom_metadata ? `
<div class="detail-row">
<div class="detail-label">Custom Metadata</div>
<div class="detail-value"><pre>${JSON.stringify(artifact.custom_metadata, null, 2)}</pre></div>
</div>
` : ''}
${artifact.description ? `
<div class="detail-row">
<div class="detail-label">Description</div>
<div class="detail-value">${escapeHtml(artifact.description)}</div>
</div>
` : ''}
${artifact.tags && artifact.tags.length > 0 ? `
<div class="detail-row">
<div class="detail-label">Tags</div>
<div class="detail-value">${formatTags(artifact.tags)}</div>
</div>
` : ''}
<div class="detail-row">
<div class="detail-label">Version</div>
<div class="detail-value">${artifact.version || '-'}</div>
</div>
<div class="detail-row">
<div class="detail-label">Created</div>
<div class="detail-value">${formatDate(artifact.created_at)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Updated</div>
<div class="detail-value">${formatDate(artifact.updated_at)}</div>
</div>
<div style="margin-top: 20px; display: flex; gap: 10px;">
<button onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" class="btn btn-primary">
<i data-lucide="download" style="width: 16px; height: 16px;"></i> Download
</button>
<button onclick="deleteArtifact(${artifact.id}); closeDetailModal();" class="btn btn-danger">
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i> Delete
</button>
</div>
`;
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();
}

View File

@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
from app.database import SessionLocal from app.database import SessionLocal
from app.models.artifact import Artifact from app.models.artifact import Artifact
from app.models.tag import Tag
from app.storage import get_storage_backend from app.storage import get_storage_backend
from app.config import settings from app.config import settings
@@ -48,6 +49,22 @@ TAGS = [
"integration", "unit", "e2e", "api" "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: def generate_csv_content() -> bytes:
"""Generate random CSV test data""" """Generate random CSV test data"""
@@ -184,6 +201,49 @@ def get_file_type(filename: str) -> str:
return type_mapping.get(extension, 'binary') 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]: async def generate_seed_data(num_artifacts: int = 50) -> List[int]:
""" """
Generate and upload seed data to the database and storage. 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 = [] artifact_ids = []
try: 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"Deployment mode: {settings.deployment_mode}")
print(f"Storage backend: {settings.storage_backend}") print(f"Storage backend: {settings.storage_backend}")
@@ -295,24 +359,21 @@ async def generate_seed_data(num_artifacts: int = 50) -> List[int]:
async def clear_all_data(): 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! WARNING: This will delete ALL data!
""" """
db = SessionLocal() db = SessionLocal()
storage = get_storage_backend() storage = get_storage_backend()
try: try:
print("Clearing all artifacts...") print("Clearing all data...")
# Get all artifacts # Clear artifacts
artifacts = db.query(Artifact).all() artifacts = db.query(Artifact).all()
count = len(artifacts) artifact_count = len(artifacts)
if count == 0: if artifact_count > 0:
print("No artifacts to delete.") print(f"Found {artifact_count} artifacts to delete...")
return
print(f"Found {count} artifacts to delete...")
# Delete from storage and database # Delete from storage and database
for i, artifact in enumerate(artifacts): for i, artifact in enumerate(artifacts):
@@ -327,10 +388,25 @@ async def clear_all_data():
db.delete(artifact) db.delete(artifact)
if (i + 1) % 10 == 0: if (i + 1) % 10 == 0:
print(f" Deleted {i + 1}/{count} artifacts...") print(f" Deleted {i + 1}/{artifact_count} artifacts...")
db.commit() db.commit()
print(f"✓ Successfully deleted {count} artifacts") print(f"✓ Successfully deleted {artifact_count} artifacts")
else:
print("No artifacts to delete.")
# Clear tags
tags = db.query(Tag).all()
tag_count = len(tags)
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: except Exception as e:
db.rollback() db.rollback()