Compare commits

..

6 Commits

Author SHA1 Message Date
pratik
2ee203b012 update styling 2025-10-15 12:02:47 -05:00
pratik
ca91fdfa15 Fix text visibility and table alignment in dark theme
- Improve badge colors for better readability (white text on blue background)
- Fix count badge with bright blue background and white text
- Fix type chip visibility with brighter blue color
- Fix filename cell alignment by using inline-flex instead of flex
- Improve header chip text color visibility
- Increase artifact fetch limit from 25 to 1000 to show all artifacts
- Add proper styling for accent buttons in toolbar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:49:00 -05:00
pratik
9733eec2af Add Claude settings to gitignore 2025-10-15 11:44:23 -05:00
pratik
9303f3481b Merge main into f/updates
Resolved conflicts by keeping f/updates changes:
- Keep Angular frontend with dark theme styling
- Keep updated quickstart scripts at root level
- Remove static HTML/JS files (replaced by Angular)
- Keep sim_source_id field implementation
- Merge backend improvements from main

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:44:01 -05:00
pratik
fbb1dfa67b Integrate dark theme styling from main branch with Angular Material
- Apply dark blue color scheme (#0f172a, #1e293b, #334155) throughout UI
- Update header with blue gradient and Obsidian branding
- Add missing toolbar buttons: Auto-refresh, Seed Data, Search filter
- Implement action buttons (Download, Delete) in artifacts table
- Add client-side search/filtering functionality
- Update app to support sim_source_id field in database
- Move quickstart scripts to repository root for easier access
- Apply dark theme to tables, tabs, and all Material components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:42:34 -05:00
pratik
82e8cea256 Switch to angular 2025-10-14 23:32:38 -05:00
113 changed files with 21668 additions and 4995 deletions

View File

@@ -0,0 +1,43 @@
{
"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:*)"
],
"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 (critical for fixing esbuild platform issue)
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

View File

@@ -27,9 +27,3 @@ MINIO_SECURE=false
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
MAX_UPLOAD_SIZE=524288000 MAX_UPLOAD_SIZE=524288000
# NPM Configuration (for frontend build)
# Default: https://registry.npmjs.org/ (public npm registry)
# For restricted environments, set to your custom npm proxy/registry URL
# Example: http://your-nexus-server:8081/repository/npm-proxy/
NPM_REGISTRY=https://registry.npmjs.org/

8
.gitignore vendored
View File

@@ -86,10 +86,4 @@ helm/charts/
tmp/ tmp/
temp/ temp/
*.tmp *.tmp
.claude/settings.local.json
# Node.js
package-lock.json
**/package-lock.json
# Built static files (generated during Docker build from Angular)
static/

View File

@@ -1,46 +1,164 @@
stages: stages:
- test
- build - build
- deploy - deploy
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
LATEST_TAG: $CI_REGISTRY_IMAGE:latest
build_container: # Test stage
stage: build test:
image: deps.global.bsf.tools/quay.io/buildah/stable:latest stage: test
variables: image: python:3.11-slim
IMAGE_NAME: "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME"
before_script: before_script:
- mkdir -p /tmp/buildah-storage - apt-get update && apt-get install -y gcc postgresql-client
- export BUILDAH_ROOT="/tmp/buildah-storage" - pip install -r requirements.txt
- echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - pip install pytest pytest-asyncio httpx
script: script:
- buildah bud --build-arg NPM_REGISTRY=https://deps.global.bsf.tools/artifactory/api/npm/registry.npmjs.org/ --storage-driver vfs --isolation chroot -t $IMAGE_NAME . - echo "Running tests..."
- buildah push --storage-driver vfs $IMAGE_NAME - python -m pytest tests/ -v || echo "No tests found, skipping"
only:
- branches
- merge_requests
deploy_helm_charts: # Lint stage
stage: deploy lint:
image: stage: test
name: deps.global.bsf.tools/registry-1.docker.io/alpine/k8s:1.29.12 image: python:3.11-slim
parallel: before_script:
matrix: - pip install flake8 black
# - ENV: "prod"
# VALUES_FILE: "helm/values-prod.yaml"
# CONTEXT: "esv/bsf/bsf-services/gitlab-kaas-agent-config:services-prod-agent"
# NAMESPACE: "bsf-services-namespace"
# ONLY: "main"
- ENV: "dev"
VALUES_FILE: "helm/warehouse13/values.yaml"
CONTEXT: "esv/bsf/bsf-services/gitlab-kaas-agent-config:services-prod-agent"
NAMESPACE: "bsf-services-dev-namespace"
# ONLY: ["branches", "!main"]
script: script:
- kubectl config use-context $CONTEXT - echo "Running linters..."
- flake8 app/ --max-line-length=120 --ignore=E203,W503 || true
- black --check app/ || true
only:
- branches
- merge_requests
allow_failure: true
# Build Docker image
build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- echo "Building Docker image..."
- docker build -t $IMAGE_TAG -t $LATEST_TAG .
- docker push $IMAGE_TAG
- docker push $LATEST_TAG
only:
- main
- master
- develop
- tags
# Deploy to development
deploy:dev:
stage: deploy
image: alpine/helm:latest
before_script:
- apk add --no-cache curl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_DEV" | base64 -d > ~/.kube/config
script:
- echo "Deploying to development environment..."
- | - |
helm upgrade --install warehouse13-$CI_COMMIT_REF_NAME \ helm upgrade --install datalake-dev ./helm \
./helm/warehouse13 --namespace $NAMESPACE \ --namespace datalake-dev \
-f $VALUES_FILE \ --create-namespace \
--set api.image=$CI_REGISTRY_IMAGE \ --set image.repository=$CI_REGISTRY_IMAGE \
--set api.image.tag=$CI_COMMIT_REF_NAME \ --set image.tag=$CI_COMMIT_SHORT_SHA \
--set postgres.image.repository=containers.global.bsf.tools/postgres \ --set ingress.enabled=true \
--set postgres.image.tag=15-alpine \ --set ingress.hosts[0].host=datalake-dev.example.com \
--set minio.image.repository=containers.global.bsf.tools/minio \ --set ingress.hosts[0].paths[0].path=/ \
--set minio.image.tag=latest --set ingress.hosts[0].paths[0].pathType=Prefix \
--wait \
--timeout 5m
environment:
name: development
url: https://datalake-dev.example.com
only:
- develop
when: manual
# Deploy to staging
deploy:staging:
stage: deploy
image: alpine/helm:latest
before_script:
- apk add --no-cache curl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_STAGING" | base64 -d > ~/.kube/config
script:
- echo "Deploying to staging environment..."
- |
helm upgrade --install datalake-staging ./helm \
--namespace datalake-staging \
--create-namespace \
--set image.repository=$CI_REGISTRY_IMAGE \
--set image.tag=$CI_COMMIT_SHORT_SHA \
--set ingress.enabled=true \
--set ingress.hosts[0].host=datalake-staging.example.com \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix \
--set resources.requests.cpu=1000m \
--set resources.requests.memory=1Gi \
--wait \
--timeout 5m
environment:
name: staging
url: https://datalake-staging.example.com
only:
- main
- master
when: manual
# Deploy to production
deploy:prod:
stage: deploy
image: alpine/helm:latest
before_script:
- apk add --no-cache curl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_PROD" | base64 -d > ~/.kube/config
script:
- echo "Deploying to production environment..."
- |
helm upgrade --install datalake ./helm \
--namespace datalake-prod \
--create-namespace \
--set image.repository=$CI_REGISTRY_IMAGE \
--set image.tag=$CI_COMMIT_SHORT_SHA \
--set replicaCount=3 \
--set ingress.enabled=true \
--set ingress.hosts[0].host=datalake.example.com \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix \
--set resources.requests.cpu=2000m \
--set resources.requests.memory=2Gi \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=3 \
--set autoscaling.maxReplicas=10 \
--wait \
--timeout 10m
environment:
name: production
url: https://datalake.example.com
only:
- tags
when: manual

View File

@@ -1,164 +0,0 @@
stages:
- test
- build
- deploy
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
LATEST_TAG: $CI_REGISTRY_IMAGE:latest
# Test stage
test:
stage: test
image: python:3.11-slim
before_script:
- apt-get update && apt-get install -y gcc postgresql-client
- pip install -r requirements.txt
- pip install pytest pytest-asyncio httpx
script:
- echo "Running tests..."
- python -m pytest tests/ -v || echo "No tests found, skipping"
only:
- branches
- merge_requests
# Lint stage
lint:
stage: test
image: python:3.11-slim
before_script:
- pip install flake8 black
script:
- echo "Running linters..."
- flake8 app/ --max-line-length=120 --ignore=E203,W503 || true
- black --check app/ || true
only:
- branches
- merge_requests
allow_failure: true
# Build Docker image
build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- echo "Building Docker image..."
- docker build -t $IMAGE_TAG -t $LATEST_TAG .
- docker push $IMAGE_TAG
- docker push $LATEST_TAG
only:
- main
- master
- develop
- tags
# Deploy to development
deploy:dev:
stage: deploy
image: alpine/helm:latest
before_script:
- apk add --no-cache curl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_DEV" | base64 -d > ~/.kube/config
script:
- echo "Deploying to development environment..."
- |
helm upgrade --install datalake-dev ./helm \
--namespace datalake-dev \
--create-namespace \
--set image.repository=$CI_REGISTRY_IMAGE \
--set image.tag=$CI_COMMIT_SHORT_SHA \
--set ingress.enabled=true \
--set ingress.hosts[0].host=datalake-dev.example.com \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix \
--wait \
--timeout 5m
environment:
name: development
url: https://datalake-dev.example.com
only:
- develop
when: manual
# Deploy to staging
deploy:staging:
stage: deploy
image: alpine/helm:latest
before_script:
- apk add --no-cache curl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_STAGING" | base64 -d > ~/.kube/config
script:
- echo "Deploying to staging environment..."
- |
helm upgrade --install datalake-staging ./helm \
--namespace datalake-staging \
--create-namespace \
--set image.repository=$CI_REGISTRY_IMAGE \
--set image.tag=$CI_COMMIT_SHORT_SHA \
--set ingress.enabled=true \
--set ingress.hosts[0].host=datalake-staging.example.com \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix \
--set resources.requests.cpu=1000m \
--set resources.requests.memory=1Gi \
--wait \
--timeout 5m
environment:
name: staging
url: https://datalake-staging.example.com
only:
- main
- master
when: manual
# Deploy to production
deploy:prod:
stage: deploy
image: alpine/helm:latest
before_script:
- apk add --no-cache curl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_PROD" | base64 -d > ~/.kube/config
script:
- echo "Deploying to production environment..."
- |
helm upgrade --install datalake ./helm \
--namespace datalake-prod \
--create-namespace \
--set image.repository=$CI_REGISTRY_IMAGE \
--set image.tag=$CI_COMMIT_SHORT_SHA \
--set replicaCount=3 \
--set ingress.enabled=true \
--set ingress.hosts[0].host=datalake.example.com \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix \
--set resources.requests.cpu=2000m \
--set resources.requests.memory=2Gi \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=3 \
--set autoscaling.maxReplicas=10 \
--wait \
--timeout 10m
environment:
name: production
url: https://datalake.example.com
only:
- tags
when: manual

View File

@@ -1,30 +1,3 @@
# Multi-stage build: First stage builds Angular frontend
FROM node:24-alpine AS frontend-build
# Accept npm registry as build argument
ARG NPM_REGISTRY=https://registry.npmjs.org/
WORKDIR /frontend
# Copy package files
COPY frontend/package*.json ./
# Configure npm registry 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"; \
fi
# Install dependencies (ignore package-lock.json if using custom registry)
RUN npm install
# Copy source code
COPY frontend/ ./
# Build for production
RUN npm run build:prod
# Second stage: Python backend with Angular frontend
FROM python:3.11-alpine FROM python:3.11-alpine
WORKDIR /app WORKDIR /app
@@ -48,9 +21,6 @@ COPY utils/ ./utils/
COPY alembic/ ./alembic/ COPY alembic/ ./alembic/
COPY alembic.ini . COPY alembic.ini .
# Copy built Angular frontend from first stage to static directory
COPY --from=frontend-build /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
USER appuser USER appuser

38
Dockerfile.frontend Normal file
View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# Warehouse13 # Obsidian
**Enterprise Test Artifact Storage** **Enterprise Test Artifact Storage**
@@ -36,32 +36,18 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
## Quick Start ## Quick Start
### Standard Deployment (Internet Access) ### One-Command Setup
**Linux/macOS:** **Linux/macOS:**
```bash ```bash
./quickstart.sh ./scripts/quickstart.sh
``` ```
**Windows (PowerShell):** **Windows (PowerShell):**
```powershell ```powershell
.\quickstart.ps1 .\scripts\quickstart.ps1
``` ```
### Air-Gapped/Restricted Environment Deployment
**For environments with restricted npm access:**
```bash
./quickstart-airgap.sh
```
This script:
1. Builds Angular locally (where npm works)
2. Packages pre-built files into Docker
3. Starts all services
See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for detailed instructions.
### Manual Setup with Docker Compose ### Manual Setup with Docker Compose
1. Clone the repository: 1. Clone the repository:
@@ -214,54 +200,35 @@ MINIO_BUCKET_NAME=test-artifacts
### Kubernetes with Helm ### Kubernetes with Helm
**Quick Start:** 1. Build and push Docker image:
```bash ```bash
helm install warehouse13 ./helm/warehouse13 --namespace warehouse13 --create-namespace docker build -t your-registry/datalake:latest .
docker push your-registry/datalake:latest
``` ```
**Production Deployment:** 2. Install with Helm:
```bash ```bash
helm install warehouse13 ./helm/warehouse13 \ helm install datalake ./helm \
--namespace warehouse13 \ --set image.repository=your-registry/datalake \
--create-namespace \ --set image.tag=latest \
--values ./helm/warehouse13/values-production.yaml --namespace datalake \
--create-namespace
``` ```
**Air-Gapped Deployment:** 3. Access the API:
```bash ```bash
helm install warehouse13 ./helm/warehouse13 \ kubectl port-forward -n datalake svc/datalake 8000:8000
--namespace warehouse13 \
--create-namespace \
--values ./helm/warehouse13/values-airgapped.yaml
``` ```
**Access the Application:**
```bash
kubectl port-forward -n warehouse13 svc/warehouse13-frontend 4200:80
kubectl port-forward -n warehouse13 svc/warehouse13-api 8000:8000
```
### Helm Documentation
- **Full Helm Guide:** [HELM-DEPLOYMENT.md](./docs/HELM-DEPLOYMENT.md)
- **Chart README:** [helm/warehouse13/README.md](./helm/warehouse13/README.md)
- **Quick Start:** [helm/warehouse13/QUICKSTART.md](./helm/warehouse13/QUICKSTART.md)
- **Example Configurations:**
- Development: [values-dev.yaml](./helm/warehouse13/values-dev.yaml)
- Production: [values-production.yaml](./helm/warehouse13/values-production.yaml)
- Air-Gapped: [values-airgapped.yaml](./helm/warehouse13/values-airgapped.yaml)
### Helm Configuration ### Helm Configuration
All component images are fully configurable in `helm/warehouse13/values.yaml`: Edit `helm/values.yaml` to customize:
- PostgreSQL image and version - Replica count
- MinIO image and version - Resource limits
- API image and version - Storage backend (S3 vs MinIO)
- Frontend image and version - Ingress settings
- Resource limits and requests - PostgreSQL settings
- Storage backend configuration - Autoscaling
- Ingress and TLS settings
- Persistence and storage classes
### GitLab CI/CD ### GitLab CI/CD
@@ -336,6 +303,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

@@ -1,9 +1,8 @@
from fastapi import FastAPI, Request from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.api.artifacts import router as artifacts_router from app.api.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
@@ -19,8 +18,8 @@ logger = logging.getLogger(__name__)
# Create FastAPI app # Create FastAPI app
app = FastAPI( app = FastAPI(
title="Warehouse13", 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,9 +37,9 @@ 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)
# Static directory setup # Note: Frontend is now served separately as an Angular application
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
@app.on_event("startup") @app.on_event("startup")
@@ -57,7 +56,7 @@ async def startup_event():
async def api_root(): async def api_root():
"""API root endpoint""" """API root endpoint"""
return { return {
"message": "Warehouse13 - 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,
@@ -67,43 +66,15 @@ async def api_root():
@app.get("/") @app.get("/")
async def ui_root(): async def ui_root():
"""Serve the UI""" """API root - Frontend is served separately"""
index_path = os.path.join(static_dir, "index.html") return {
if os.path.exists(index_path): "message": "Test Artifact Data Lake API",
return FileResponse(index_path) "version": "1.0.0",
else: "docs": "/docs",
return { "frontend": "Frontend is served separately on port 4200 (development) or via reverse proxy (production)",
"message": "Warehouse13 - Enterprise Test Artifact Storage", "deployment_mode": settings.deployment_mode,
"version": "1.0.0", "storage_backend": settings.storage_backend
"docs": "/docs", }
"ui": "UI not found. Serving API only.",
"deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend
}
# Catch-all route for Angular SPA routing - must be last
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve Angular SPA static files and handle client-side routing"""
# Try to serve static file first (JS, CSS, images, etc.)
file_path = os.path.join(static_dir, full_path)
if os.path.exists(file_path) and os.path.isfile(file_path):
return FileResponse(file_path)
# For all other routes (Angular client-side routes), serve index.html
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
else:
return {
"message": "Warehouse13 - Enterprise Test Artifact Storage",
"version": "1.0.0",
"docs": "/docs",
"ui": "UI not found. Serving API only.",
"deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend
}
@app.get("/health") @app.get("/health")

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

@@ -1,7 +1,5 @@
version: '3.8' version: '3.8'
name: warehouse13
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
@@ -36,12 +34,8 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
app: api:
container_name: warehouse13-app build: .
build:
context: .
args:
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/}
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
@@ -63,6 +57,9 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
# Frontend service removed from default compose - use dev-start.sh for development
# For production with built frontend, use: docker-compose -f docker-compose.production.yml up
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:

View File

@@ -1,140 +1,465 @@
# Deployment Options # Deployment Guide
This project supports two deployment strategies for the Angular frontend, depending on your environment's network access. This guide covers deploying the Test Artifact Data Lake in various environments.
## Option 1: Standard Build (Internet Access Required) ## Table of Contents
- [Local Development](#local-development)
Use the standard `Dockerfile.frontend` which builds the Angular app inside Docker. - [Docker Compose](#docker-compose)
- [Kubernetes/Helm](#kuberneteshelm)
**Requirements:** - [AWS Deployment](#aws-deployment)
- Internet access to npm registry - [Self-Hosted Deployment](#self-hosted-deployment)
- Docker build environment - [GitLab CI/CD](#gitlab-cicd)
**Usage:**
```bash
./quickstart.sh
# or
docker-compose up -d --build
```
This uses `Dockerfile.frontend` which:
1. Installs npm dependencies in Docker
2. Builds Angular app in Docker
3. Serves with nginx
--- ---
## Option 2: Pre-built Deployment (Air-Gapped/Restricted Environments) ⭐ RECOMMENDED ## Local Development
Use `Dockerfile.frontend.prebuilt` for environments with restricted npm access. ### Prerequisites
- Python 3.11+
- PostgreSQL 15+
- MinIO or AWS S3 access
**Requirements:** ### Steps
- Node.js 18+ installed locally (on a machine with npm access)
- npm installed locally
- No internet required during Docker build
**Note:** This project uses Angular 17 with webpack bundler (not Vite) for better compatibility with restricted npm environments. 1. **Create virtual environment:**
**Usage:**
### Quick Start (Recommended)
```bash ```bash
./quickstart-airgap.sh python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
``` ```
This script will: 2. **Install dependencies:**
1. Build the Angular app locally
2. Start all Docker containers
3. Verify the deployment
### Manual Steps
### Step 1: Build Angular app locally
**IMPORTANT:** You MUST run this step BEFORE `docker-compose up`!
```bash ```bash
# Option A: Use the helper script pip install -r requirements.txt
./scripts/build-for-airgap.sh
# Option B: Build manually
cd frontend
npm install # Only needed once or when dependencies change
npm run build:prod
cd ..
``` ```
This creates `frontend/dist/frontend/browser/` which Docker will copy. 3. **Set up PostgreSQL:**
```bash
createdb datalake
```
### Step 2: Update docker-compose.yml 4. **Configure environment:**
Edit `docker-compose.yml` and change the frontend dockerfile: ```bash
cp .env.example .env
# Edit .env with your configuration
```
5. **Run the application:**
```bash
python -m uvicorn app.main:app --reload
```
---
## Docker Compose
### Quick Start
1. **Start all services:**
```bash
docker-compose up -d
```
2. **Check logs:**
```bash
docker-compose logs -f api
```
3. **Stop services:**
```bash
docker-compose down
```
### Services Included
- PostgreSQL (port 5432)
- MinIO (port 9000, console 9001)
- API (port 8000)
### Customization
Edit `docker-compose.yml` to:
- Change port mappings
- Adjust resource limits
- Add environment variables
- Configure volumes
---
## Kubernetes/Helm
### Prerequisites
- Kubernetes cluster (1.24+)
- Helm 3.x
- kubectl configured
### Installation
1. **Add dependencies (if using PostgreSQL/MinIO from Bitnami):**
```bash
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
```
2. **Install with default values:**
```bash
helm install datalake ./helm \
--namespace datalake \
--create-namespace
```
3. **Custom installation:**
```bash
helm install datalake ./helm \
--namespace datalake \
--create-namespace \
--set image.repository=your-registry/datalake \
--set image.tag=1.0.0 \
--set ingress.enabled=true \
--set ingress.hosts[0].host=datalake.yourdomain.com
```
### Configuration Options
**Image:**
```bash
--set image.repository=your-registry/datalake
--set image.tag=1.0.0
--set image.pullPolicy=Always
```
**Resources:**
```bash
--set resources.requests.cpu=1000m
--set resources.requests.memory=1Gi
--set resources.limits.cpu=2000m
--set resources.limits.memory=2Gi
```
**Autoscaling:**
```bash
--set autoscaling.enabled=true
--set autoscaling.minReplicas=3
--set autoscaling.maxReplicas=10
--set autoscaling.targetCPUUtilizationPercentage=80
```
**Ingress:**
```bash
--set ingress.enabled=true
--set ingress.className=nginx
--set ingress.hosts[0].host=datalake.example.com
--set ingress.hosts[0].paths[0].path=/
--set ingress.hosts[0].paths[0].pathType=Prefix
```
### Upgrade
```bash
helm upgrade datalake ./helm \
--namespace datalake \
--set image.tag=1.1.0
```
### Uninstall
```bash
helm uninstall datalake --namespace datalake
```
---
## AWS Deployment
### Using AWS S3 Storage
1. **Create S3 bucket:**
```bash
aws s3 mb s3://your-test-artifacts-bucket
```
2. **Create IAM user with S3 access:**
```bash
aws iam create-user --user-name datalake-service
aws iam attach-user-policy --user-name datalake-service \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
```
3. **Generate access keys:**
```bash
aws iam create-access-key --user-name datalake-service
```
4. **Deploy with Helm:**
```bash
helm install datalake ./helm \
--namespace datalake \
--create-namespace \
--set config.storageBackend=s3 \
--set aws.enabled=true \
--set aws.accessKeyId=YOUR_ACCESS_KEY \
--set aws.secretAccessKey=YOUR_SECRET_KEY \
--set aws.region=us-east-1 \
--set aws.bucketName=your-test-artifacts-bucket \
--set minio.enabled=false
```
### Using EKS
1. **Create EKS cluster:**
```bash
eksctl create cluster \
--name datalake-cluster \
--region us-east-1 \
--nodegroup-name standard-workers \
--node-type t3.medium \
--nodes 3
```
2. **Configure kubectl:**
```bash
aws eks update-kubeconfig --name datalake-cluster --region us-east-1
```
3. **Deploy application:**
```bash
helm install datalake ./helm \
--namespace datalake \
--create-namespace \
--set config.storageBackend=s3
```
### Using RDS for PostgreSQL
```bash
helm install datalake ./helm \
--namespace datalake \
--create-namespace \
--set postgresql.enabled=false \
--set config.databaseUrl="postgresql://user:pass@your-rds-endpoint:5432/datalake"
```
---
## Self-Hosted Deployment
### Using MinIO
1. **Deploy MinIO:**
```bash
helm install minio bitnami/minio \
--namespace datalake \
--create-namespace \
--set auth.rootUser=admin \
--set auth.rootPassword=adminpassword \
--set persistence.size=100Gi
```
2. **Deploy application:**
```bash
helm install datalake ./helm \
--namespace datalake \
--set config.storageBackend=minio \
--set minio.enabled=false \
--set minio.endpoint=minio:9000 \
--set minio.accessKey=admin \
--set minio.secretKey=adminpassword
```
### On-Premise Kubernetes
1. **Prepare persistent volumes:**
```yaml ```yaml
frontend: apiVersion: v1
build: kind: PersistentVolume
context: . metadata:
dockerfile: Dockerfile.frontend.prebuilt # <-- Change this line name: datalake-postgres-pv
ports: spec:
- "4200:80" capacity:
depends_on: storage: 20Gi
- api accessModes:
- ReadWriteOnce
hostPath:
path: /data/postgres
``` ```
### Step 3: Build and deploy 2. **Deploy with local storage:**
```bash ```bash
docker-compose up -d --build helm install datalake ./helm \
--namespace datalake \
--create-namespace \
--set postgresql.persistence.storageClass=local-storage \
--set minio.persistence.storageClass=local-storage
``` ```
This uses `Dockerfile.frontend.prebuilt` which: ---
1. Copies pre-built Angular files from `frontend/dist/`
2. Serves with nginx ## GitLab CI/CD
3. No npm/node required in Docker
### Setup
1. **Configure GitLab variables:**
Go to Settings → CI/CD → Variables and add:
| Variable | Description | Protected | Masked |
|----------|-------------|-----------|---------|
| `CI_REGISTRY_USER` | Docker registry username | No | No |
| `CI_REGISTRY_PASSWORD` | Docker registry password | No | Yes |
| `KUBE_CONFIG_DEV` | Base64 kubeconfig for dev | No | Yes |
| `KUBE_CONFIG_STAGING` | Base64 kubeconfig for staging | Yes | Yes |
| `KUBE_CONFIG_PROD` | Base64 kubeconfig for prod | Yes | Yes |
2. **Encode kubeconfig:**
```bash
cat ~/.kube/config | base64 -w 0
```
### Pipeline Stages
1. **Test**: Runs on all branches and MRs
2. **Build**: Builds Docker image on main/develop/tags
3. **Deploy**: Manual deployment to dev/staging/prod
### Deployment Flow
**Development:**
```bash
git push origin develop
# Manually trigger deploy:dev job in GitLab
```
**Staging:**
```bash
git push origin main
# Manually trigger deploy:staging job in GitLab
```
**Production:**
```bash
git tag v1.0.0
git push origin v1.0.0
# Manually trigger deploy:prod job in GitLab
```
### Customizing Pipeline
Edit `.gitlab-ci.yml` to:
- Add more test stages
- Change deployment namespaces
- Adjust Helm values per environment
- Add security scanning
- Configure rollback procedures
---
## Monitoring
### Health Checks
```bash
# Kubernetes
kubectl get pods -n datalake
kubectl logs -f -n datalake deployment/datalake
# Direct
curl http://localhost:8000/health
```
### Metrics
Add Prometheus monitoring:
```bash
helm install datalake ./helm \
--set metrics.enabled=true \
--set serviceMonitor.enabled=true
```
---
## Backup and Recovery
### Database Backup
```bash
# PostgreSQL
kubectl exec -n datalake deployment/datalake-postgresql -- \
pg_dump -U user datalake > backup.sql
# Restore
kubectl exec -i -n datalake deployment/datalake-postgresql -- \
psql -U user datalake < backup.sql
```
### Storage Backup
**S3:**
```bash
aws s3 sync s3://your-bucket s3://backup-bucket
```
**MinIO:**
```bash
mc mirror minio/test-artifacts backup/test-artifacts
```
--- ---
## Troubleshooting ## Troubleshooting
### Build Tool Package Issues ### Pod Not Starting
If you see errors about missing packages like:
```
Cannot find package "vite"
Cannot find package "esbuild"
Cannot find package "rollup"
```
**Solution:** This project uses Angular 17 with webpack bundler specifically to avoid these issues. If you still encounter package access problems in your restricted environment, use Option 2 (Pre-built) deployment above, which eliminates all npm dependencies in Docker.
### Custom NPM Registry
For both options, you can use a custom npm registry:
```bash ```bash
# Set in .env file kubectl describe pod -n datalake <pod-name>
NPM_REGISTRY=http://your-npm-proxy:8081/repository/npm-proxy/ kubectl logs -n datalake <pod-name>
```
# Or inline ### Database Connection Issues
NPM_REGISTRY=http://your-proxy ./quickstart.sh ```bash
kubectl exec -it -n datalake deployment/datalake -- \
psql $DATABASE_URL
```
### Storage Issues
```bash
# Check MinIO
kubectl port-forward -n datalake svc/minio 9000:9000
# Access http://localhost:9000
``` ```
--- ---
## Recommendation ## Security Considerations
- **Development/Cloud**: Use Option 1 (standard) 1. **Use secrets management:**
- **Air-gapped/Enterprise**: Use Option 2 (pre-built) ⭐ **RECOMMENDED** - Kubernetes Secrets
- **CI/CD**: Use Option 2 for faster, more reliable builds - AWS Secrets Manager
- **Restricted npm access**: Use Option 2 (pre-built) ⭐ **REQUIRED** - HashiCorp Vault
2. **Enable TLS:**
- Configure ingress with TLS certificates
- Use cert-manager for automatic certificates
3. **Network policies:**
- Restrict pod-to-pod communication
- Limit external access
4. **RBAC:**
- Configure Kubernetes RBAC
- Limit service account permissions
--- ---
## Build Strategy for Restricted Environments ## Performance Tuning
**This project uses Angular 17 with webpack** instead of Angular 19 with Vite specifically for better compatibility with restricted npm environments. Webpack has fewer platform-specific binary dependencies than Vite. ### Database
- Increase connection pool size
- Add database indexes
- Configure autovacuum
If you encounter any package access errors during builds: ### API
- `Cannot find package "vite"` - Increase replica count
- `Cannot find package "rollup"` - Configure horizontal pod autoscaling
- `Cannot find package "esbuild"` - Adjust resource requests/limits
- Any platform-specific binary errors
**Solution:** Use Option 2 (Pre-built) deployment. This completely avoids npm installation in Docker and eliminates all build tool dependency issues. ### Storage
- Use CDN for frequently accessed files
- Configure S3 Transfer Acceleration
- Optimize MinIO deployment

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

View File

@@ -1,517 +0,0 @@
# Warehouse13 - Kubernetes Deployment with Helm
This guide covers deploying Warehouse13 to Kubernetes using the official Helm chart.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Quick Start](#quick-start)
3. [Deployment Scenarios](#deployment-scenarios)
4. [Configuration](#configuration)
5. [Post-Deployment](#post-deployment)
6. [Upgrading](#upgrading)
7. [Troubleshooting](#troubleshooting)
## Prerequisites
- Kubernetes 1.19+ cluster
- Helm 3.0+
- kubectl configured to access your cluster
- Persistent volume provisioner (for production deployments)
### Installing Helm
```bash
# macOS
brew install helm
# Linux
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Windows
choco install kubernetes-helm
```
## Quick Start
### 1. Standard Deployment (Internet Access)
```bash
# Create namespace
kubectl create namespace warehouse13
# Install with default values
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13
# Wait for pods to be ready
kubectl wait --for=condition=ready pod \
--all --namespace warehouse13 --timeout=300s
```
### 2. Access the Application
```bash
# Frontend
kubectl port-forward -n warehouse13 svc/warehouse13-frontend 4200:80
# API
kubectl port-forward -n warehouse13 svc/warehouse13-api 8000:8000
# MinIO Console
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9001:9001
```
Then visit:
- Frontend: http://localhost:4200
- API Docs: http://localhost:8000/docs
- MinIO Console: http://localhost:9001
## Deployment Scenarios
### Development Environment
For local testing or CI/CD:
```bash
helm install warehouse13-dev ./helm/warehouse13 \
--namespace warehouse13-dev \
--create-namespace \
--values ./helm/warehouse13/values-dev.yaml
```
**Features:**
- Single replica for all services
- emptyDir storage (no persistence)
- Minimal resource requests
- Always pull latest dev images
### Production Environment
For production with ingress and high availability:
```bash
# First, update the values file with your domain and secrets
cp ./helm/warehouse13/values-production.yaml ./my-production-values.yaml
# Edit the file:
# - Set postgres.auth.password
# - Set minio.auth.rootUser and rootPassword
# - Set ingress.hosts[0].host to your domain
# - Update storageClass for your environment
# Install
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--create-namespace \
--values ./my-production-values.yaml
```
**Features:**
- 3 replicas for API and frontend
- Persistent storage with PVCs
- Ingress with TLS support
- Resource limits and requests
- Health checks enabled
- Pod anti-affinity for distribution
### Air-Gapped Environment
For restricted/disconnected environments:
```bash
# 1. First, push images to your internal registry
# Example using harbor.internal.example.com
# Pull images (on internet-connected machine)
docker pull postgres:15-alpine
docker pull minio/minio:latest
docker pull warehouse13/api:v1.0.0
docker pull warehouse13/frontend:v1.0.0
# Tag for internal registry
docker tag postgres:15-alpine harbor.internal.example.com/library/postgres:15-alpine
docker tag minio/minio:latest harbor.internal.example.com/library/minio:latest
docker tag warehouse13/api:v1.0.0 harbor.internal.example.com/warehouse13/api:v1.0.0
docker tag warehouse13/frontend:v1.0.0 harbor.internal.example.com/warehouse13/frontend:v1.0.0
# Push to internal registry
docker push harbor.internal.example.com/library/postgres:15-alpine
docker push harbor.internal.example.com/library/minio:latest
docker push harbor.internal.example.com/warehouse13/api:v1.0.0
docker push harbor.internal.example.com/warehouse13/frontend:v1.0.0
# 2. Update the values file with your registry
cp ./helm/warehouse13/values-airgapped.yaml ./my-airgapped-values.yaml
# Edit to match your environment:
# - Update all image.repository values
# - Set secure passwords
# - Configure storage classes
# - Add node selectors/tolerations if needed
# 3. Install on air-gapped cluster
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--create-namespace \
--values ./my-airgapped-values.yaml
```
**Features:**
- All images from custom registry
- Local storage class support
- Node selectors for specific nodes
- Tolerations for tainted nodes
## Configuration
### Configurable Images
All component images can be customized:
```yaml
# PostgreSQL
postgres:
image:
repository: postgres # or your-registry/postgres
tag: 15-alpine
pullPolicy: IfNotPresent
# MinIO
minio:
image:
repository: minio/minio # or your-registry/minio
tag: latest
pullPolicy: IfNotPresent
# API Backend
api:
image:
repository: warehouse13/api # or your-registry/warehouse13-api
tag: v1.0.0
pullPolicy: IfNotPresent
# Frontend
frontend:
image:
repository: warehouse13/frontend # or your-registry/warehouse13-frontend
tag: v1.0.0
pullPolicy: IfNotPresent
```
### Quick Image Override
```bash
# Override images from command line
helm install warehouse13 ./helm/warehouse13 \
--set postgres.image.repository=myregistry.com/postgres \
--set postgres.image.tag=15-alpine \
--set minio.image.repository=myregistry.com/minio \
--set minio.image.tag=latest \
--set api.image.repository=myregistry.com/warehouse13-api \
--set api.image.tag=v1.0.0 \
--set frontend.image.repository=myregistry.com/warehouse13-frontend \
--set frontend.image.tag=v1.0.0
```
### Storage Configuration
```yaml
# PostgreSQL storage
postgres:
persistence:
enabled: true
size: 50Gi
storageClass: "fast-ssd" # or "" for default
# MinIO storage
minio:
persistence:
enabled: true
size: 500Gi
storageClass: "bulk-storage" # or "" for default
```
### Resource Configuration
```yaml
# API resources
api:
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
# Frontend resources
frontend:
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
```
### Ingress Configuration
```yaml
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts:
- host: warehouse13.example.com
paths:
- path: /
pathType: Prefix
backend: frontend
- path: /api
pathType: Prefix
backend: api
tls:
- secretName: warehouse13-tls
hosts:
- warehouse13.example.com
```
## Post-Deployment
### Verify Installation
```bash
# Check all pods are running
kubectl get pods -n warehouse13
# Check services
kubectl get svc -n warehouse13
# Check PVCs
kubectl get pvc -n warehouse13
# Check ingress (if enabled)
kubectl get ingress -n warehouse13
```
### View Logs
```bash
# API logs
kubectl logs -n warehouse13 -l app.kubernetes.io/component=api --tail=100 -f
# Frontend logs
kubectl logs -n warehouse13 -l app.kubernetes.io/component=frontend --tail=100 -f
# PostgreSQL logs
kubectl logs -n warehouse13 warehouse13-postgres-0 --tail=100 -f
# MinIO logs
kubectl logs -n warehouse13 warehouse13-minio-0 --tail=100 -f
```
### Initialize MinIO Bucket
```bash
# Port-forward to MinIO console
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9001:9001
# Open http://localhost:9001
# Login with credentials from values.yaml
# Create bucket: "artifacts"
```
## Upgrading
### Upgrade to New Version
```bash
# Update image tags in values file
# Then run upgrade
helm upgrade warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--values ./my-production-values.yaml \
--wait \
--timeout 10m
# Check rollout status
kubectl rollout status deployment/warehouse13-api -n warehouse13
kubectl rollout status deployment/warehouse13-frontend -n warehouse13
```
### Rollback
```bash
# View revision history
helm history warehouse13 -n warehouse13
# Rollback to previous version
helm rollback warehouse13 -n warehouse13
# Rollback to specific revision
helm rollback warehouse13 2 -n warehouse13
```
### Update Values Only
```bash
# Update configuration without changing images
helm upgrade warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--values ./my-updated-values.yaml \
--reuse-values
```
## Backup and Restore
### PostgreSQL Backup
```bash
# Create backup
kubectl exec -n warehouse13 warehouse13-postgres-0 -- \
pg_dump -U warehouse13user warehouse13 > backup-$(date +%Y%m%d).sql
# Restore
cat backup-20241016.sql | kubectl exec -i -n warehouse13 warehouse13-postgres-0 -- \
psql -U warehouse13user warehouse13
```
### MinIO Backup
```bash
# Install MinIO Client
wget https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
# Configure
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9000:9000
mc alias set w13 http://localhost:9000 <access-key> <secret-key>
# Backup bucket
mc mirror w13/artifacts ./backup/artifacts-$(date +%Y%m%d)
# Restore
mc mirror ./backup/artifacts-20241016 w13/artifacts
```
### Full Backup
```bash
# Backup all PVCs
for pvc in $(kubectl get pvc -n warehouse13 -o name); do
pvc_name=$(basename $pvc)
kubectl get -n warehouse13 $pvc -o yaml > backup-${pvc_name}.yaml
done
# Backup Helm values
helm get values warehouse13 -n warehouse13 > backup-values.yaml
```
## Troubleshooting
### Pods Not Starting
```bash
# Check pod status
kubectl get pods -n warehouse13
# Describe pod for events
kubectl describe pod <pod-name> -n warehouse13
# Check logs
kubectl logs <pod-name> -n warehouse13
# Common issues:
# - ImagePullBackOff: Check image repository and credentials
# - Pending: Check PVC status and node resources
# - CrashLoopBackOff: Check application logs
```
### PVC Issues
```bash
# Check PVC status
kubectl get pvc -n warehouse13
# Describe PVC
kubectl describe pvc <pvc-name> -n warehouse13
# Common issues:
# - Pending: No storage class or insufficient storage
# - Bound: PVC is healthy
```
### Database Connection Issues
```bash
# Test PostgreSQL connection
kubectl exec -it -n warehouse13 warehouse13-postgres-0 -- \
psql -U warehouse13user -d warehouse13
# Check database logs
kubectl logs -n warehouse13 warehouse13-postgres-0 --tail=100
# Verify secret
kubectl get secret -n warehouse13 warehouse13-secrets -o yaml
```
### Ingress Not Working
```bash
# Check ingress status
kubectl get ingress -n warehouse13
kubectl describe ingress -n warehouse13 warehouse13-ingress
# Check ingress controller logs
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller
# Verify TLS certificate
kubectl get certificate -n warehouse13
kubectl describe certificate -n warehouse13 warehouse13-tls
```
### Performance Issues
```bash
# Check resource usage
kubectl top pods -n warehouse13
kubectl top nodes
# Check if pods are being throttled
kubectl describe pod <pod-name> -n warehouse13 | grep -A 5 "State:"
# Increase resources
helm upgrade warehouse13 ./helm/warehouse13 \
--set api.resources.limits.memory=2Gi \
--set api.resources.limits.cpu=2000m
```
## Uninstalling
```bash
# Uninstall the release
helm uninstall warehouse13 -n warehouse13
# Delete PVCs (data will be lost!)
kubectl delete pvc -n warehouse13 -l app.kubernetes.io/instance=warehouse13
# Delete namespace
kubectl delete namespace warehouse13
```
## Additional Resources
- [Helm Chart README](./helm/warehouse13/README.md)
- [Values Documentation](./helm/warehouse13/values.yaml)
- [Docker Deployment Guide](./DEPLOYMENT.md)
- [Main README](./README.md)
## Support
For issues and questions:
- GitHub Issues: https://github.com/yourusername/warehouse13/issues
- Helm Chart Issues: Tag with `helm` label

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!

View File

@@ -164,7 +164,7 @@ curl -X POST "http://localhost:8000/api/v1/artifacts/query" \
make deploy make deploy
# Or directly with Helm # Or directly with Helm
helm install warehouse13 ./helm/warehouse13 --namespace warehouse13 --create-namespace helm install datalake ./helm --namespace datalake --create-namespace
``` ```
## Feature Flags Usage ## Feature Flags Usage
@@ -190,8 +190,9 @@ AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket S3_BUCKET_NAME=your-bucket
# Deploy # Deploy
helm install warehouse13 ./helm/warehouse13 \ helm install datalake ./helm \
--set global.deploymentMode=cloud --set config.deploymentMode=cloud \
--set aws.enabled=true
``` ```
## What's Next ## What's Next

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

View File

@@ -1,178 +1,151 @@
# Warehouse13 Frontend - Angular Application # Test Artifact Data Lake - Angular Frontend
Modern Angular application for the Warehouse13 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

@@ -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,14 +36,13 @@
} }
], ],
"styles": [ "styles": [
"src/styles.css" "@angular/material/prebuilt-themes/azure-blue.css",
] "src/styles.scss"
],
"scripts": []
}, },
"configurations": { "configurations": {
"production": { "production": {
"optimization" : {
"fonts": false
},
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
@@ -58,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"
@@ -70,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": "**/*",
@@ -87,14 +99,13 @@
} }
], ],
"styles": [ "styles": [
"src/styles.css" "@angular/material/prebuilt-themes/azure-blue.css",
] "src/styles.scss"
],
"scripts": []
} }
} }
} }
} }
},
"cli": {
"analytics": false
} }
} }

15273
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,81 +3,37 @@
"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"
}, },
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/common": "19.2.x", "@angular/cdk": "^19.2.19",
"@angular/compiler": "19.2.x", "@angular/common": "^19.2.0",
"@angular/core": "19.2.x", "@angular/compiler": "^19.2.0",
"@angular/forms": "19.2.x", "@angular/core": "^19.2.0",
"@angular/platform-browser": "19.2.x", "@angular/forms": "^19.2.0",
"@angular/router": "19.2.x", "@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.8.1", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "19.2.x", "@angular-devkit/build-angular": "^19.2.17",
"@angular/cli": "19.2.x", "@angular/cli": "^19.2.17",
"@angular/compiler-cli": "19.2.x", "@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0", "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.x.x", "typescript": "~5.7.2"
"undici-types": "7.12.0",
"node-releases": "2.0.21",
"node-gyp": "11.4.2",
"tar": "7.4.3",
"minizlib": "3.0.2",
"immutable": "5.1.3",
"exponential-backoff": "3.1.2",
"emoji-regex": "10.5.0",
"electron-to-chromium": "1.5.221",
"caniuse-lite": "1.0.30001743"
},
"resolutions": {
"undici-types": "7.12.0",
"node-releases": "2.0.21",
"node-gyp": "11.4.2",
"tar": "7.4.3",
"minizlib": "3.0.2",
"immutable": "5.1.3",
"exponential-backoff": "3.1.2",
"emoji-regex": "10.5.0",
"electron-to-chromium": "1.5.221",
"caniuse-lite": "1.0.30001743"
},
"overrides": {
"undici-types": "7.12.0",
"node-releases": "2.0.21",
"node-gyp": "11.4.2",
"tar": "7.4.3",
"minizlib": "3.0.2",
"immutable": "5.1.3",
"exponential-backoff": "3.1.2",
"emoji-regex": "10.5.0",
"electron-to-chromium": "1.5.221",
"caniuse-lite": "1.0.30001743"
} }
} }

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

@@ -1,21 +1,27 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { App } from './app'; import { AppComponent } from './app.component';
describe('App', () => { describe('AppComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [AppComponent],
}).compileComponents(); }).compileComponents();
}); });
it('should create the app', () => { it('should create the app', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app).toBeTruthy(); 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', () => { it('should render title', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); 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,342 +0,0 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />

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

@@ -1,53 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { ArtifactService } from './services/artifact';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="container">
<header>
<h1><span class="logo">[W13]</span></h1>
<div class="header-info">
<span class="badge">{{ deploymentMode }}</span>
<span class="badge">{{ storageBackend }}</span>
</div>
</header>
<nav class="tabs">
<a routerLink="/artifacts" routerLinkActive="active" class="tab-button">
<span class="material-icons md-16">storage</span> Artifacts
</a>
<a routerLink="/upload" routerLinkActive="active" class="tab-button">
<span class="material-icons md-16">upload</span> Upload
</a>
<a routerLink="/query" routerLinkActive="active" class="tab-button">
<span class="material-icons md-16">search</span> Query
</a>
</nav>
<router-outlet></router-outlet>
</div>
`,
styleUrls: ['./app.css']
})
export class AppComponent implements OnInit {
deploymentMode: string = '';
storageBackend: string = '';
constructor(private artifactService: ArtifactService) {}
ngOnInit() {
this.artifactService.getApiInfo().subscribe({
next: (info) => {
this.deploymentMode = `Mode: ${info.deployment_mode}`;
this.storageBackend = `Storage: ${info.storage_backend}`;
},
error: (err) => console.error('Failed to load API info:', err)
});
}
}

View File

@@ -1,190 +0,0 @@
<div class="artifacts-container">
<div class="toolbar">
<button (click)="loadArtifacts()" class="btn btn-primary">
<span class="material-icons md-16">refresh</span> Refresh
</button>
<button (click)="toggleAutoRefresh()"
[class.btn-success]="autoRefreshEnabled"
[class.btn-secondary]="!autoRefreshEnabled"
class="btn">
Auto-refresh: {{ autoRefreshEnabled ? 'ON' : 'OFF' }}
</button>
<button (click)="generateSeedData()" class="btn btn-secondary">
<span class="material-icons md-16">auto_awesome</span> Generate Seed Data
</button>
<span class="count-badge">{{ filteredArtifacts.length }} artifacts</span>
<div class="filter-inline">
<span class="material-icons md-16 search-icon">search</span>
<input
type="text"
[(ngModel)]="searchTerm"
(input)="onSearch()"
placeholder="Search..."
class="search-input">
<button (click)="clearSearch()" class="btn-clear" *ngIf="searchTerm">
<span class="material-icons md-16">close</span>
</button>
</div>
</div>
<div class="error-message" *ngIf="error">{{ error }}</div>
<div class="table-container">
<table class="artifacts-table">
<thead>
<tr>
<th class="sortable" (click)="sortTable('sim_source_id')">
Sim Source
<span class="sort-indicator" *ngIf="sortColumn === 'sim_source_id'">
{{ sortDirection === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th class="sortable" (click)="sortTable('filename')">
Artifacts
<span class="sort-indicator" *ngIf="sortColumn === 'filename'">
{{ sortDirection === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th class="sortable" (click)="sortTable('created_at')">
Date
<span class="sort-indicator" *ngIf="sortColumn === 'created_at'">
{{ sortDirection === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th class="sortable" (click)="sortTable('test_name')">
Uploaded By
<span class="sort-indicator" *ngIf="sortColumn === 'test_name'">
{{ sortDirection === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="loading">
<td colspan="5" class="loading">Loading artifacts...</td>
</tr>
<tr *ngIf="!loading && filteredArtifacts.length === 0">
<td colspan="5" class="loading">No artifacts found. Upload some files to get started!</td>
</tr>
<tr *ngFor="let artifact of filteredArtifacts" (click)="showDetail(artifact)" class="clickable">
<td>{{ artifact.sim_source_id || artifact.test_suite || '-' }}</td>
<td>
<a href="javascript:void(0)" class="artifact-link">{{ artifact.filename }}</a>
<div class="tags" *ngIf="artifact.tags && artifact.tags.length > 0">
<span class="tag" *ngFor="let tag of artifact.tags">{{ tag }}</span>
</div>
</td>
<td>{{ formatDate(artifact.created_at) }}</td>
<td>{{ artifact.test_name || '-' }}</td>
<td>
<div class="action-buttons">
<button (click)="downloadArtifact(artifact, $event)" class="icon-btn" title="Download">
<span class="material-icons md-16">download</span>
</button>
<button (click)="deleteArtifact(artifact, $event)" class="icon-btn danger" title="Delete">
<span class="material-icons md-16">delete</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button (click)="previousPage()" [disabled]="currentPage === 1" class="btn">← Previous</button>
<span class="page-info">Page {{ currentPage }}</span>
<button (click)="nextPage()" [disabled]="filteredArtifacts.length < pageSize" class="btn">Next →</button>
</div>
</div>
<!-- Detail Modal -->
<div class="modal" *ngIf="selectedArtifact" (click)="closeDetail()">
<div class="modal-content" (click)="$event.stopPropagation()">
<span class="close" (click)="closeDetail()">&times;</span>
<h2>Artifact Details</h2>
<div class="detail-content">
<div class="detail-row">
<div class="detail-label">ID</div>
<div class="detail-value">{{ selectedArtifact.id }}</div>
</div>
<div class="detail-row">
<div class="detail-label">Filename</div>
<div class="detail-value">{{ selectedArtifact.filename }}</div>
</div>
<div class="detail-row">
<div class="detail-label">File Type</div>
<div class="detail-value"><span class="file-type-badge">{{ selectedArtifact.file_type }}</span></div>
</div>
<div class="detail-row">
<div class="detail-label">Size</div>
<div class="detail-value">{{ formatBytes(selectedArtifact.file_size) }}</div>
</div>
<div class="detail-row">
<div class="detail-label">Storage Path</div>
<div class="detail-value"><code>{{ selectedArtifact.storage_path }}</code></div>
</div>
<div class="detail-row">
<div class="detail-label">Uploaded By</div>
<div class="detail-value">{{ selectedArtifact.test_name || '-' }}</div>
</div>
<div class="detail-row">
<div class="detail-label">Sim Source</div>
<div class="detail-value">{{ selectedArtifact.test_suite || '-' }}</div>
</div>
<div class="detail-row" *ngIf="selectedArtifact.sim_source_id">
<div class="detail-label">SIM Source ID</div>
<div class="detail-value">{{ selectedArtifact.sim_source_id }}</div>
</div>
<div class="detail-row" *ngIf="selectedArtifact.test_result">
<div class="detail-label">Test Result</div>
<div class="detail-value">
<span class="result-badge result-{{ selectedArtifact.test_result }}">
{{ selectedArtifact.test_result }}
</span>
</div>
</div>
<div class="detail-row" *ngIf="selectedArtifact.test_config">
<div class="detail-label">Test Config</div>
<div class="detail-value"><pre>{{ selectedArtifact.test_config | json }}</pre></div>
</div>
<div class="detail-row" *ngIf="selectedArtifact.custom_metadata">
<div class="detail-label">Custom Metadata</div>
<div class="detail-value"><pre>{{ selectedArtifact.custom_metadata | json }}</pre></div>
</div>
<div class="detail-row" *ngIf="selectedArtifact.description">
<div class="detail-label">Description</div>
<div class="detail-value">{{ selectedArtifact.description }}</div>
</div>
<div class="detail-row" *ngIf="selectedArtifact.tags && selectedArtifact.tags.length > 0">
<div class="detail-label">Tags</div>
<div class="detail-value">
<span class="tag" *ngFor="let tag of selectedArtifact.tags">{{ tag }}</span>
</div>
</div>
<div class="detail-row">
<div class="detail-label">Version</div>
<div class="detail-value">{{ selectedArtifact.version || '-' }}</div>
</div>
<div class="detail-row">
<div class="detail-label">Created</div>
<div class="detail-value">{{ formatDate(selectedArtifact.created_at) }}</div>
</div>
<div class="detail-row">
<div class="detail-label">Updated</div>
<div class="detail-value">{{ formatDate(selectedArtifact.updated_at) }}</div>
</div>
<div class="modal-actions">
<button (click)="downloadArtifact(selectedArtifact, $event)" class="btn btn-primary">
<span class="material-icons md-16">download</span> Download
</button>
<button (click)="deleteArtifact(selectedArtifact, $event); closeDetail()" class="btn btn-danger">
<span class="material-icons md-16">delete</span> Delete
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ArtifactsList } from './artifacts-list';
describe('ArtifactsList', () => {
let component: ArtifactsList;
let fixture: ComponentFixture<ArtifactsList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ArtifactsList]
})
.compileComponents();
fixture = TestBed.createComponent(ArtifactsList);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,235 +0,0 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ArtifactService } from '../../services/artifact';
import { Artifact } from '../../models/artifact.model';
import { interval, Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@Component({
selector: 'app-artifacts-list',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './artifacts-list.html',
styleUrls: ['./artifacts-list.css']
})
export class ArtifactsListComponent implements OnInit, OnDestroy {
artifacts: Artifact[] = [];
filteredArtifacts: Artifact[] = [];
selectedArtifact: Artifact | null = null;
searchTerm: string = '';
// Pagination
currentPage: number = 1;
pageSize: number = 25;
// Auto-refresh
autoRefreshEnabled: boolean = true;
private refreshSubscription?: Subscription;
private readonly REFRESH_INTERVAL = 5000; // 5 seconds
// Sorting
sortColumn: string | null = null;
sortDirection: 'asc' | 'desc' = 'asc';
loading: boolean = false;
error: string | null = null;
constructor(private artifactService: ArtifactService) {}
ngOnInit() {
this.loadArtifacts();
this.startAutoRefresh();
}
ngOnDestroy() {
this.stopAutoRefresh();
}
loadArtifacts() {
this.loading = true;
this.error = null;
const offset = (this.currentPage - 1) * this.pageSize;
this.artifactService.listArtifacts(this.pageSize, offset).subscribe({
next: (artifacts) => {
this.artifacts = artifacts;
this.applyFilter();
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load artifacts: ' + err.message;
this.loading = false;
}
});
}
applyFilter() {
if (!this.searchTerm) {
this.filteredArtifacts = [...this.artifacts];
} else {
const term = this.searchTerm.toLowerCase();
this.filteredArtifacts = this.artifacts.filter(artifact =>
artifact.filename.toLowerCase().includes(term) ||
(artifact.test_name && artifact.test_name.toLowerCase().includes(term)) ||
(artifact.test_suite && artifact.test_suite.toLowerCase().includes(term)) ||
(artifact.sim_source_id && artifact.sim_source_id.toLowerCase().includes(term))
);
}
if (this.sortColumn) {
this.applySorting();
}
}
onSearch() {
this.applyFilter();
}
clearSearch() {
this.searchTerm = '';
this.applyFilter();
}
sortTable(column: string) {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
this.applySorting();
}
applySorting() {
if (!this.sortColumn) return;
this.filteredArtifacts.sort((a, b) => {
const aVal = (a as any)[this.sortColumn!] || '';
const bVal = (b as any)[this.sortColumn!] || '';
let comparison = 0;
if (this.sortColumn === 'created_at') {
comparison = new Date(aVal).getTime() - new Date(bVal).getTime();
} else {
comparison = String(aVal).localeCompare(String(bVal));
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
}
previousPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.loadArtifacts();
}
}
nextPage() {
this.currentPage++;
this.loadArtifacts();
}
toggleAutoRefresh() {
this.autoRefreshEnabled = !this.autoRefreshEnabled;
if (this.autoRefreshEnabled) {
this.startAutoRefresh();
} else {
this.stopAutoRefresh();
}
}
private startAutoRefresh() {
if (!this.autoRefreshEnabled) return;
this.refreshSubscription = interval(this.REFRESH_INTERVAL)
.pipe(switchMap(() => this.artifactService.listArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize)))
.subscribe({
next: (artifacts) => {
this.artifacts = artifacts;
this.applyFilter();
},
error: (err) => console.error('Auto-refresh error:', err)
});
}
private stopAutoRefresh() {
if (this.refreshSubscription) {
this.refreshSubscription.unsubscribe();
}
}
showDetail(artifact: Artifact) {
this.selectedArtifact = artifact;
}
closeDetail() {
this.selectedArtifact = null;
}
downloadArtifact(artifact: Artifact, event: Event) {
event.stopPropagation();
this.artifactService.downloadArtifact(artifact.id).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = artifact.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
error: (err) => alert('Download failed: ' + err.message)
});
}
deleteArtifact(artifact: Artifact, event: Event) {
event.stopPropagation();
if (!confirm(`Are you sure you want to delete ${artifact.filename}? This cannot be undone.`)) {
return;
}
this.artifactService.deleteArtifact(artifact.id).subscribe({
next: () => {
this.loadArtifacts();
if (this.selectedArtifact?.id === artifact.id) {
this.closeDetail();
}
},
error: (err) => alert('Delete failed: ' + err.message)
});
}
generateSeedData() {
const count = prompt('How many artifacts to generate? (1-100)', '10');
if (!count) return;
const num = parseInt(count);
if (isNaN(num) || num < 1 || num > 100) {
alert('Please enter a number between 1 and 100');
return;
}
this.artifactService.generateSeedData(num).subscribe({
next: (result) => {
alert(result.message);
this.loadArtifacts();
},
error: (err) => alert('Generation failed: ' + err.message)
});
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
}
}

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

@@ -1,102 +0,0 @@
<div class="query-section">
<h2>Query Artifacts</h2>
<form [formGroup]="queryForm" (ngSubmit)="onSubmit()">
<div class="form-row">
<div class="form-group">
<label for="q-filename">Filename</label>
<input
type="text"
id="q-filename"
formControlName="filename"
placeholder="Search filename...">
</div>
<div class="form-group">
<label for="q-type">File Type</label>
<select id="q-type" formControlName="file_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"
formControlName="test_name"
placeholder="Search test name...">
</div>
<div class="form-group">
<label for="q-suite">Test Suite</label>
<input
type="text"
id="q-suite"
formControlName="test_suite"
placeholder="e.g., integration">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="q-sim-source-id">SIM Source ID</label>
<input
type="text"
id="q-sim-source-id"
formControlName="sim_source_id"
placeholder="e.g., sim_run_abc123">
</div>
<div class="form-group">
<label for="q-result">Test Result</label>
<select id="q-result" formControlName="test_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>
<div class="form-row">
<div class="form-group">
<label for="q-tags">Tags (comma-separated)</label>
<input
type="text"
id="q-tags"
formControlName="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"
formControlName="start_date">
</div>
<div class="form-group">
<label for="q-end-date">End Date</label>
<input
type="datetime-local"
id="q-end-date"
formControlName="end_date">
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary btn-large">
<span class="material-icons md-18">search</span> Search
</button>
<button type="button" (click)="clearForm()" class="btn btn-secondary">
<span class="material-icons md-18">close</span> Clear
</button>
</div>
</form>
</div>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { QueryForm } from './query-form';
describe('QueryForm', () => {
let component: QueryForm;
let fixture: ComponentFixture<QueryForm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QueryForm]
})
.compileComponents();
fixture = TestBed.createComponent(QueryForm);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,83 +0,0 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
import { ArtifactService } from '../../services/artifact';
import { Artifact, ArtifactQuery } from '../../models/artifact.model';
@Component({
selector: 'app-query-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './query-form.html',
styleUrls: ['./query-form.css']
})
export class QueryFormComponent {
queryForm: FormGroup;
@Output() resultsFound = new EventEmitter<Artifact[]>();
constructor(
private fb: FormBuilder,
private artifactService: ArtifactService
) {
this.queryForm = this.fb.group({
filename: [''],
file_type: [''],
test_name: [''],
test_suite: [''],
test_result: [''],
sim_source_id: [''],
tags: [''],
start_date: [''],
end_date: ['']
});
}
onSubmit() {
const query: ArtifactQuery = {
limit: 100,
offset: 0
};
if (this.queryForm.value.filename) {
query.filename = this.queryForm.value.filename;
}
if (this.queryForm.value.file_type) {
query.file_type = this.queryForm.value.file_type;
}
if (this.queryForm.value.test_name) {
query.test_name = this.queryForm.value.test_name;
}
if (this.queryForm.value.test_suite) {
query.test_suite = this.queryForm.value.test_suite;
}
if (this.queryForm.value.test_result) {
query.test_result = this.queryForm.value.test_result;
}
if (this.queryForm.value.sim_source_id) {
query.sim_source_id = this.queryForm.value.sim_source_id;
}
if (this.queryForm.value.tags) {
query.tags = this.queryForm.value.tags
.split(',')
.map((t: string) => t.trim())
.filter((t: string) => t);
}
if (this.queryForm.value.start_date) {
query.start_date = new Date(this.queryForm.value.start_date).toISOString();
}
if (this.queryForm.value.end_date) {
query.end_date = new Date(this.queryForm.value.end_date).toISOString();
}
this.artifactService.queryArtifacts(query).subscribe({
next: (artifacts) => {
this.resultsFound.emit(artifacts);
},
error: (err) => alert('Query failed: ' + err.message)
});
}
clearForm() {
this.queryForm.reset();
}
}

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

@@ -1,109 +0,0 @@
<div class="upload-section">
<h2>Upload Artifact</h2>
<form [formGroup]="uploadForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="file">File *</label>
<input type="file" id="file" (change)="onFileSelected($event)" 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"
formControlName="sim_source"
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"
formControlName="uploaded_by"
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"
formControlName="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"
formControlName="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" formControlName="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"
formControlName="version"
placeholder="e.g., v1.0.0">
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
formControlName="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"
formControlName="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"
formControlName="custom_metadata"
rows="4"
placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
</div>
<button type="submit" class="btn btn-primary btn-large" [disabled]="uploading">
<span class="material-icons md-18">upload</span>
{{ uploading ? 'Uploading...' : 'Upload Artifact' }}
</button>
</form>
<div *ngIf="uploadStatus" class="upload-status" [class.success]="uploadStatus.success" [class.error]="!uploadStatus.success">
{{ uploadStatus.message }}
</div>
</div>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UploadForm } from './upload-form';
describe('UploadForm', () => {
let component: UploadForm;
let fixture: ComponentFixture<UploadForm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UploadForm]
})
.compileComponents();
fixture = TestBed.createComponent(UploadForm);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,132 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ArtifactService } from '../../services/artifact';
@Component({
selector: 'app-upload-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './upload-form.html',
styleUrls: ['./upload-form.css']
})
export class UploadFormComponent {
uploadForm: FormGroup;
selectedFile: File | null = null;
uploading: boolean = false;
uploadStatus: { message: string, success: boolean } | null = null;
constructor(
private fb: FormBuilder,
private artifactService: ArtifactService
) {
this.uploadForm = this.fb.group({
file: [null, Validators.required],
sim_source: ['', Validators.required],
uploaded_by: ['', Validators.required],
sim_source_id: [''],
tags: ['', Validators.required],
test_result: [''],
version: [''],
description: [''],
test_config: [''],
custom_metadata: ['']
});
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0];
this.uploadForm.patchValue({ file: this.selectedFile });
}
}
onSubmit() {
if (!this.uploadForm.valid || !this.selectedFile) {
this.showStatus('Please fill in all required fields and select a file', false);
return;
}
// Validate JSON fields
const testConfig = this.uploadForm.value.test_config;
const customMetadata = this.uploadForm.value.custom_metadata;
if (testConfig) {
try {
JSON.parse(testConfig);
} catch (e) {
this.showStatus('Invalid Test Config JSON', false);
return;
}
}
if (customMetadata) {
try {
JSON.parse(customMetadata);
} catch (e) {
this.showStatus('Invalid Custom Metadata JSON', false);
return;
}
}
const formData = new FormData();
formData.append('file', this.selectedFile);
formData.append('test_suite', this.uploadForm.value.sim_source);
formData.append('test_name', this.uploadForm.value.uploaded_by);
if (this.uploadForm.value.sim_source_id) {
formData.append('sim_source_id', this.uploadForm.value.sim_source_id);
}
// Parse and append tags as JSON array
if (this.uploadForm.value.tags) {
const tagsArray = this.uploadForm.value.tags
.split(',')
.map((t: string) => t.trim())
.filter((t: string) => t);
formData.append('tags', JSON.stringify(tagsArray));
}
if (this.uploadForm.value.test_result) {
formData.append('test_result', this.uploadForm.value.test_result);
}
if (this.uploadForm.value.version) {
formData.append('version', this.uploadForm.value.version);
}
if (this.uploadForm.value.description) {
formData.append('description', this.uploadForm.value.description);
}
if (testConfig) {
formData.append('test_config', testConfig);
}
if (customMetadata) {
formData.append('custom_metadata', customMetadata);
}
this.uploading = true;
this.artifactService.uploadArtifact(formData).subscribe({
next: (artifact) => {
this.showStatus(`Successfully uploaded: ${artifact.filename}`, true);
this.uploadForm.reset();
this.selectedFile = null;
this.uploading = false;
},
error: (err) => {
this.showStatus('Upload failed: ' + err.error?.detail || err.message, false);
this.uploading = false;
}
});
}
private showStatus(message: string, success: boolean) {
this.uploadStatus = { message, success };
setTimeout(() => {
this.uploadStatus = null;
}, 5000);
}
}

View File

@@ -4,19 +4,18 @@ export interface Artifact {
file_type: string; file_type: string;
file_size: number; file_size: number;
storage_path: string; storage_path: string;
content_type: string | null; test_name?: string;
test_name: string | null; test_suite?: string;
test_suite: string | null; test_result?: 'pass' | 'fail' | 'skip' | 'error';
test_config: any; test_config?: any;
test_result: string | null; custom_metadata?: any;
sim_source_id: string | null; description?: string;
custom_metadata: any; tags: string[];
description: string | null; version?: string;
tags: string[] | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
version: string | null; event_id?: string;
parent_id: number | null; binaries?: string[];
} }
export interface ArtifactQuery { export interface ArtifactQuery {
@@ -25,7 +24,6 @@ export interface ArtifactQuery {
test_name?: string; test_name?: string;
test_suite?: string; test_suite?: string;
test_result?: string; test_result?: string;
sim_source_id?: string;
tags?: string[]; tags?: string[];
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
@@ -36,5 +34,18 @@ export interface ArtifactQuery {
export interface ApiInfo { export interface ApiInfo {
deployment_mode: string; deployment_mode: string;
storage_backend: string; storage_backend: string;
version: 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

@@ -1,51 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Artifact, ArtifactQuery, ApiInfo } from '../models/artifact.model';
@Injectable({
providedIn: 'root'
})
export class ArtifactService {
private apiUrl = '/api/v1/artifacts';
private baseUrl = '/api';
constructor(private http: HttpClient) {}
getApiInfo(): Observable<ApiInfo> {
return this.http.get<ApiInfo>(this.baseUrl);
}
listArtifacts(limit: number = 100, offset: number = 0): Observable<Artifact[]> {
const params = new HttpParams()
.set('limit', limit.toString())
.set('offset', offset.toString());
return this.http.get<Artifact[]>(this.apiUrl + '/', { params });
}
getArtifact(id: number): Observable<Artifact> {
return this.http.get<Artifact>(`${this.apiUrl}/${id}`);
}
queryArtifacts(query: ArtifactQuery): Observable<Artifact[]> {
return this.http.post<Artifact[]>(`${this.apiUrl}/query`, query);
}
uploadArtifact(formData: FormData): Observable<Artifact> {
return this.http.post<Artifact>(`${this.apiUrl}/upload`, formData);
}
downloadArtifact(id: number): Observable<Blob> {
return this.http.get(`${this.apiUrl}/${id}/download`, {
responseType: 'blob'
});
}
deleteArtifact(id: number): Observable<any> {
return this.http.delete(`${this.apiUrl}/${id}`);
}
generateSeedData(count: number): Observable<any> {
return this.http.post(`/api/v1/seed/generate/${count}`, {});
}
}

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

View File

@@ -1,3 +1,5 @@
/* Global styles for Obsidian - Dark Theme inspired from main branch */
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -15,14 +17,14 @@ body {
.container { .container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
background: #1e293b; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden; overflow: hidden;
} }
header { header {
background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 30px; padding: 30px;
display: flex; display: flex;
@@ -33,21 +35,6 @@ header {
header h1 { header h1 {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.logo {
font-family: 'Courier New', monospace;
font-weight: 700;
font-size: 24px;
color: #60a5fa;
letter-spacing: -1px;
padding: 2px 4px;
border: 2px solid #60a5fa;
border-radius: 4px;
background: rgba(96, 165, 250, 0.1);
} }
.header-info { .header-info {
@@ -67,8 +54,8 @@ header h1 {
.tabs { .tabs {
display: flex; display: flex;
background: #0f172a; background: #f7f9fc;
border-bottom: 2px solid #334155; border-bottom: 2px solid #e2e8f0;
} }
.tab-button { .tab-button {
@@ -80,22 +67,17 @@ header h1 {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
color: #94a3b8; color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
} }
.tab-button:hover { .tab-button:hover {
background: #1e293b; background: #e2e8f0;
color: #e2e8f0;
} }
.tab-button.active { .tab-button.active {
background: #1e293b; background: white;
color: #60a5fa; color: #667eea;
border-bottom: 3px solid #60a5fa; border-bottom: 3px solid #667eea;
} }
.tab-content { .tab-content {
@@ -114,52 +96,6 @@ header h1 {
align-items: center; 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 { .btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
@@ -168,28 +104,25 @@ header h1 {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: #667eea;
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: #2563eb; background: #5568d3;
transform: translateY(-1px); transform: translateY(-1px);
} }
.btn-secondary { .btn-secondary {
background: #334155; background: #e2e8f0;
color: #e2e8f0; color: #475569;
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #475569; background: #cbd5e1;
} }
.btn-danger { .btn-danger {
@@ -211,9 +144,14 @@ header h1 {
font-size: 16px; font-size: 16px;
} }
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.count-badge { .count-badge {
background: #1e3a8a; background: #f0f9ff;
color: #93c5fd; color: #0369a1;
padding: 8px 16px; padding: 8px 16px;
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 13px;
@@ -223,9 +161,8 @@ header h1 {
.table-container { .table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #334155; border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 8px;
background: #0f172a;
} }
table { table {
@@ -235,61 +172,30 @@ table {
} }
thead { thead {
background: #1e293b; background: #f7f9fc;
} }
th { th {
padding: 14px 12px; padding: 14px 12px;
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
color: #94a3b8; color: #475569;
border-bottom: 2px solid #334155; border-bottom: 2px solid #e2e8f0;
white-space: nowrap; 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 { td {
padding: 16px 12px; padding: 12px;
border-bottom: 1px solid #1e293b; border-bottom: 1px solid #e2e8f0;
color: #cbd5e1;
} }
tbody tr:hover { tbody tr:hover {
background: #1e293b; background: #f7f9fc;
} }
.loading { .loading {
text-align: center; text-align: center;
color: #64748b; color: #94a3b8;
padding: 40px !important; padding: 40px !important;
} }
@@ -302,38 +208,68 @@ tbody tr:hover {
} }
.result-pass { .result-pass {
background: #064e3b; background: #d1fae5;
color: #6ee7b7; color: #065f46;
} }
.result-fail { .result-fail {
background: #7f1d1d; background: #fee2e2;
color: #fca5a5; color: #991b1b;
} }
.result-skip { .result-skip {
background: #78350f; background: #fef3c7;
color: #fcd34d; color: #92400e;
} }
.result-error { .result-error {
background: #7f1d1d; background: #fecaca;
color: #fca5a5; color: #7f1d1d;
} }
.tag { .tag {
display: inline-block; display: inline-block;
background: #1e3a8a; background: #e0e7ff;
color: #93c5fd; color: #3730a3;
padding: 3px 8px; padding: 3px 8px;
border-radius: 10px; border-radius: 10px;
font-size: 11px; font-size: 11px;
margin: 2px; margin: 2px;
cursor: pointer;
transition: all 0.2s;
}
.tag:hover {
background: #c7d2fe;
}
.tag.removable {
background: #f87171;
color: white;
}
.tag.removable:hover {
background: #ef4444;
}
.tag.available {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.tag.available:hover:not(.attached) {
background: #e5e7eb;
}
.tag.attached {
opacity: 0.5;
cursor: not-allowed;
} }
.file-type-badge { .file-type-badge {
background: #1e3a8a; background: #dbeafe;
color: #93c5fd; color: #1e40af;
padding: 4px 8px; padding: 4px 8px;
border-radius: 6px; border-radius: 6px;
font-size: 11px; font-size: 11px;
@@ -352,7 +288,7 @@ tbody tr:hover {
#page-info { #page-info {
font-weight: 500; font-weight: 500;
color: #94a3b8; color: #64748b;
} }
.upload-section, .query-section { .upload-section, .query-section {
@@ -370,10 +306,16 @@ tbody tr:hover {
gap: 20px; gap: 20px;
} }
.form-actions {
display: flex;
gap: 10px;
align-items: center;
}
label { label {
display: block; display: block;
font-weight: 500; font-weight: 500;
color: #cbd5e1; color: #475569;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 14px; font-size: 14px;
} }
@@ -385,49 +327,56 @@ select,
textarea { textarea {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid #334155; border: 1px solid #e2e8f0;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: inherit;
transition: border-color 0.3s; transition: border-color 0.3s;
background: #0f172a;
color: #e2e8f0;
} }
input:focus, input:focus,
select:focus, select:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: #667eea;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
} }
small { small {
color: #64748b; color: #94a3b8;
font-size: 12px; font-size: 12px;
display: block; display: block;
margin-top: 4px; margin-top: 4px;
} }
#upload-status { .upload-status {
margin-top: 20px; margin-top: 20px;
padding: 14px; padding: 14px;
border-radius: 6px; border-radius: 6px;
display: none; display: none;
} }
#upload-status.success { .upload-status.success {
background: #064e3b; background: #d1fae5;
color: #6ee7b7; color: #065f46;
display: block; display: block;
} }
#upload-status.error { .upload-status.error {
background: #7f1d1d; background: #fee2e2;
color: #fca5a5; color: #991b1b;
display: block; display: block;
} }
.selected-file {
margin-top: 8px;
padding: 8px;
background: #f0f9ff;
border-radius: 4px;
font-size: 12px;
color: #0369a1;
}
.modal { .modal {
display: none; display: none;
position: fixed; position: fixed;
@@ -436,7 +385,7 @@ small {
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -447,15 +396,14 @@ small {
} }
.modal-content { .modal-content {
background: #1e293b; background: white;
padding: 30px; padding: 30px;
border-radius: 12px; border-radius: 12px;
max-width: 700px; max-width: 700px;
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
border: 1px solid #334155;
} }
.close { .close {
@@ -464,19 +412,19 @@ small {
top: 20px; top: 20px;
font-size: 28px; font-size: 28px;
font-weight: bold; font-weight: bold;
color: #64748b; color: #94a3b8;
cursor: pointer; cursor: pointer;
transition: color 0.3s; transition: color 0.3s;
} }
.close:hover { .close:hover {
color: #e2e8f0; color: #475569;
} }
.detail-row { .detail-row {
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1px solid #334155; border-bottom: 1px solid #e2e8f0;
} }
.detail-row:last-child { .detail-row:last-child {
@@ -485,29 +433,20 @@ small {
.detail-label { .detail-label {
font-weight: 600; font-weight: 600;
color: #94a3b8; color: #475569;
margin-bottom: 4px; margin-bottom: 4px;
} }
.detail-value { .detail-value {
color: #cbd5e1; color: #64748b;
} }
pre { pre {
background: #0f172a; background: #f7f9fc;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
overflow-x: auto; overflow-x: auto;
font-size: 12px; font-size: 12px;
border: 1px solid #334155;
}
code {
background: #0f172a;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: #93c5fd;
} }
.action-buttons { .action-buttons {
@@ -519,68 +458,203 @@ code {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 8px; font-size: 18px;
padding: 6px;
border-radius: 4px; border-radius: 4px;
transition: all 0.3s; transition: background 0.3s;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
} }
.icon-btn:hover { .icon-btn:hover {
background: #334155; background: #e2e8f0;
color: #e2e8f0;
transform: scale(1.1);
} }
/* Ensure SVG icons inherit color */ .filename-link {
.icon-btn svg { color: #667eea;
stroke: currentColor;
}
/* Artifact link styles - softer blue */
.artifact-link {
color: #93c5fd;
text-decoration: none; text-decoration: none;
transition: color 0.3s;
} }
.artifact-link:hover { .filename-link:hover {
color: #bfdbfe;
text-decoration: underline; text-decoration: underline;
} }
/* Clickable row cursor */ /* Binaries and Tags cells */
tr.clickable { .binaries-cell, .tags-cell {
max-width: 200px;
}
.binaries-list, .tags-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.binary-item {
background: #f3f4f6;
color: #374151;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-family: monospace;
}
.expand-btn {
background: #e5e7eb;
color: #374151;
border: none;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
cursor: pointer; cursor: pointer;
transition: background 0.2s;
} }
/* Search icon color */ .expand-btn:hover {
.search-icon { background: #d1d5db;
color: #64748b;
} }
/* Material Icons */ .expanded-binaries, .expanded-tags {
.material-icons { margin-top: 8px;
font-family: 'Material Icons'; display: flex;
font-weight: normal; flex-wrap: wrap;
font-style: normal; gap: 4px;
font-size: 20px; }
/* Tag Manager */
.tag-manager {
position: relative;
display: inline-block; display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
vertical-align: middle;
} }
.material-icons.md-16 { font-size: 16px; } .current-tags {
.material-icons.md-18 { font-size: 18px; } display: flex;
.material-icons.md-20 { font-size: 20px; } flex-wrap: wrap;
.material-icons.md-24 { font-size: 24px; } gap: 4px;
margin-bottom: 8px;
}
.add-tag-btn {
background: #667eea;
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.add-tag-btn:hover {
background: #5568d3;
}
.add-tag-btn.active {
background: #ef4444;
}
.add-tag-form {
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 300px;
}
.tag-input, .scope-select, .scope-custom-input {
width: 100%;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
}
.scope-toggle-btn {
background: #f3f4f6;
color: #374151;
border: none;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
margin-bottom: 8px;
}
.scope-input {
display: flex;
gap: 8px;
}
.available-tags {
margin-top: 16px;
max-height: 200px;
overflow-y: auto;
}
.available-tags h4 {
font-size: 12px;
color: #475569;
margin-bottom: 8px;
}
.tag-group {
margin-bottom: 12px;
}
.tag-group h5 {
font-size: 11px;
color: #6b7280;
margin-bottom: 4px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.attached-indicator {
margin-left: 4px;
color: #10b981;
}
/* Filter chips */
.filter-info {
margin-top: 20px;
padding: 16px;
background: #f7f9fc;
border-radius: 8px;
}
.filter-info h4 {
font-size: 14px;
color: #475569;
margin-bottom: 8px;
}
.active-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-chip {
background: #667eea;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.form-row { .form-row {
@@ -601,7 +675,36 @@ tr.clickable {
padding: 8px 6px; padding: 8px 6px;
} }
.toolbar { .binaries-cell, .tags-cell {
flex-wrap: wrap; max-width: 150px;
} }
} }
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
/* Material Snackbar Styles */
.success-snackbar {
background-color: #4caf50 !important;
color: white !important;
}
.error-snackbar {
background-color: #f44336 !important;
color: white !important;
}
.info-snackbar {
background-color: #2196f3 !important;
color: white !important;
}
.warning-snackbar {
background-color: #ff9800 !important;
color: white !important;
}
.confirmation-snackbar {
background-color: #673ab7 !important;
color: white !important;
}

View File

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

13
helm/Chart.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v2
name: datalake
description: Test Artifact Data Lake - Store and query test artifacts
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
- testing
- artifacts
- storage
- data-lake
maintainers:
- name: Your Team

View File

@@ -1,42 +0,0 @@
# Helm Charts
This directory contains Helm charts for deploying Warehouse13.
## Current Chart (Recommended)
**Location:** `./warehouse13/`
The latest, fully-featured Helm chart with:
- Warehouse13 branding
- Configurable images for all components
- Multiple deployment scenarios (dev, production, air-gapped)
- Comprehensive documentation
- Example values files
**Usage:**
```bash
helm install warehouse13 ./warehouse13
```
**Documentation:** See [warehouse13/README.md](./warehouse13/README.md)
## Migration from Legacy Chart
If you were using an older version of the chart, migration is straightforward:
```bash
# Uninstall old chart (if named "datalake" or other name)
helm uninstall <old-release-name>
# Install new chart
helm install warehouse13 ./warehouse13 --namespace warehouse13 --create-namespace
# Or upgrade in place (if compatible)
helm upgrade <old-release-name> ./warehouse13
```
Note: Check your values.yaml configuration and update image repositories, resource limits, and other settings as needed.
## Quick Start
See [../docs/HELM-DEPLOYMENT.md](../docs/HELM-DEPLOYMENT.md) for comprehensive deployment guide.

View File

@@ -1,14 +1,14 @@
{{/* {{/*
Expand the name of the chart. Expand the name of the chart.
*/}} */}}
{{- define "warehouse13.name" -}} {{- define "datalake.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }} {{- end }}
{{/* {{/*
Create a default fully qualified app name. Create a default fully qualified app name.
*/}} */}}
{{- define "warehouse13.fullname" -}} {{- define "datalake.fullname" -}}
{{- if .Values.fullnameOverride }} {{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }} {{- else }}
@@ -24,16 +24,16 @@ Create a default fully qualified app name.
{{/* {{/*
Create chart name and version as used by the chart label. Create chart name and version as used by the chart label.
*/}} */}}
{{- define "warehouse13.chart" -}} {{- define "datalake.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }} {{- end }}
{{/* {{/*
Common labels Common labels
*/}} */}}
{{- define "warehouse13.labels" -}} {{- define "datalake.labels" -}}
helm.sh/chart: {{ include "warehouse13.chart" . }} helm.sh/chart: {{ include "datalake.chart" . }}
{{ include "warehouse13.selectorLabels" . }} {{ include "datalake.selectorLabels" . }}
{{- if .Chart.AppVersion }} {{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }} {{- end }}
@@ -43,29 +43,18 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
{{/* {{/*
Selector labels Selector labels
*/}} */}}
{{- define "warehouse13.selectorLabels" -}} {{- define "datalake.selectorLabels" -}}
app.kubernetes.io/name: {{ include "warehouse13.name" . }} app.kubernetes.io/name: {{ include "datalake.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }} {{- end }}
{{/* {{/*
Create the name of the service account to use Create the name of the service account to use
*/}} */}}
{{- define "warehouse13.serviceAccountName" -}} {{- define "datalake.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }} {{- if .Values.serviceAccount.create }}
{{- default (include "warehouse13.fullname" .) .Values.serviceAccount.name }} {{- default (include "datalake.fullname" .) .Values.serviceAccount.name }}
{{- else }} {{- else }}
{{- default "default" .Values.serviceAccount.name }} {{- default "default" .Values.serviceAccount.name }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{/*
PostgreSQL connection string
*/}}
{{- define "warehouse13.postgresUrl" -}}
{{- if .Values.app.env.databaseUrl }}
{{- .Values.app.env.databaseUrl }}
{{- else }}
{{- printf "postgresql://%s:%s@warehouse13-postgres:%d/%s" .Values.postgres.auth.username .Values.postgres.auth.password (.Values.postgres.service.port | int) .Values.postgres.auth.database }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,111 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "datalake.fullname" . }}
labels:
{{- include "datalake.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "datalake.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "datalake.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "datalake.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 5
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "datalake.fullname" . }}-secrets
key: database-url
- name: STORAGE_BACKEND
value: {{ .Values.config.storageBackend | quote }}
- name: MAX_UPLOAD_SIZE
value: {{ .Values.config.maxUploadSize | quote }}
{{- if eq .Values.config.storageBackend "s3" }}
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "datalake.fullname" . }}-secrets
key: aws-access-key-id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "datalake.fullname" . }}-secrets
key: aws-secret-access-key
- name: AWS_REGION
value: {{ .Values.aws.region | quote }}
- name: S3_BUCKET_NAME
value: {{ .Values.aws.bucketName | quote }}
{{- else }}
- name: MINIO_ENDPOINT
value: "{{ include "datalake.fullname" . }}-minio:9000"
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "datalake.fullname" . }}-secrets
key: minio-access-key
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "datalake.fullname" . }}-secrets
key: minio-secret-key
- name: MINIO_BUCKET_NAME
value: "test-artifacts"
- name: MINIO_SECURE
value: "false"
{{- end }}
{{- with .Values.env }}
{{- toYaml . | nindent 8 }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -2,9 +2,9 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: warehouse13-ingress name: {{ include "datalake.fullname" . }}
labels: labels:
{{- include "warehouse13.labels" . | nindent 4 }} {{- include "datalake.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }} {{- with .Values.ingress.annotations }}
annotations: annotations:
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}
@@ -33,9 +33,9 @@ spec:
pathType: {{ .pathType }} pathType: {{ .pathType }}
backend: backend:
service: service:
name: {{ printf "warehouse13-%s" .backend }} name: {{ include "datalake.fullname" $ }}
port: port:
number: {{ $.Values.app.service.port }} number: {{ $.Values.service.port }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }} {{- end }}

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "datalake.fullname" . }}-secrets
labels:
{{- include "datalake.labels" . | nindent 4 }}
type: Opaque
stringData:
database-url: "postgresql://{{ .Values.postgresql.auth.username }}:{{ .Values.postgresql.auth.password }}@{{ include "datalake.fullname" . }}-postgresql:5432/{{ .Values.postgresql.auth.database }}"
{{- if .Values.aws.enabled }}
aws-access-key-id: {{ .Values.aws.accessKeyId | quote }}
aws-secret-access-key: {{ .Values.aws.secretAccessKey | quote }}
{{- else }}
minio-access-key: {{ .Values.minio.rootUser | quote }}
minio-secret-key: {{ .Values.minio.rootPassword | quote }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "datalake.fullname" . }}
labels:
{{- include "datalake.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "datalake.selectorLabels" . | nindent 4 }}

View File

@@ -2,9 +2,9 @@
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: {{ include "warehouse13.serviceAccountName" . }} name: {{ include "datalake.serviceAccountName" . }}
labels: labels:
{{- include "warehouse13.labels" . | nindent 4 }} {{- include "datalake.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }} {{- with .Values.serviceAccount.annotations }}
annotations: annotations:
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}

111
helm/values.yaml Normal file
View File

@@ -0,0 +1,111 @@
replicaCount: 1
image:
repository: datalake
pullPolicy: IfNotPresent
tag: "latest"
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podSecurityContext:
fsGroup: 1000
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
service:
type: ClusterIP
port: 8000
targetPort: 8000
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: datalake.local
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
# Application configuration
config:
storageBackend: minio # or "s3"
maxUploadSize: 524288000 # 500MB
# PostgreSQL configuration
postgresql:
enabled: true
auth:
username: user
password: password
database: datalake
primary:
persistence:
enabled: true
size: 10Gi
# MinIO configuration (for self-hosted storage)
minio:
enabled: true
mode: standalone
rootUser: minioadmin
rootPassword: minioadmin
persistence:
enabled: true
size: 50Gi
service:
type: ClusterIP
port: 9000
consoleService:
port: 9001
# AWS S3 configuration (when using AWS)
aws:
enabled: false
accessKeyId: ""
secretAccessKey: ""
region: us-east-1
bucketName: test-artifacts
# Environment variables
env:
- name: API_HOST
value: "0.0.0.0"
- name: API_PORT
value: "8000"

View File

@@ -1,23 +0,0 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -1,281 +0,0 @@
# Warehouse13 Architecture
## Overview
Warehouse13 uses a **unified application container** that includes both the frontend and backend in a single Docker image using a multi-stage build.
## Docker Build Strategy
### Multi-Stage Dockerfile
```dockerfile
# Stage 1: Build Angular Frontend
FROM node:24-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build:prod
# Stage 2: Python Backend with Static Frontend
FROM python:3.11-alpine
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
# Copy backend code
COPY app/ ./app/
# Copy built frontend from stage 1
COPY --from=frontend-build /frontend/dist/frontend/browser ./static/
# Run FastAPI server
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### Benefits
1. **Simplified Deployment** - Single container to manage
2. **Reduced Resource Usage** - No separate nginx container needed
3. **Easier Scaling** - Scale one deployment instead of two
4. **Consistent Versioning** - Frontend and backend versions always match
5. **Faster Deployments** - Fewer containers to orchestrate
## Service Architecture
```
┌─────────────────────────────────────────┐
│ warehouse13-app │
│ ┌────────────────────────────────────┐ │
│ │ FastAPI Backend (Port 8000) │ │
│ │ ├── /api/* → REST API │ │
│ │ ├── /health → Health check │ │
│ │ ├── /docs → API documentation │ │
│ │ └── /* → Angular SPA │ │
│ │ │ │
│ │ Static Files: /static/ │ │
│ │ └── Angular build output │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────┘
├────────────────┐
↓ ↓
┌──────────┐ ┌────────────┐
│PostgreSQL│ │ MinIO │
│(Metadata)│ │ (Blobs) │
└──────────┘ └────────────┘
```
## Helm Chart Structure
### Single Application Deployment
The Helm chart creates:
1. **1 Deployment**: `warehouse13-app`
- Runs the unified container
- Configurable replicas (default: 2)
- Health checks on `/health` endpoint
2. **1 Service**: `warehouse13-app`
- Exposes port 8000
- Routes all traffic to the application
3. **Optional Ingress**
- All paths route to `warehouse13-app:8000`
- FastAPI handles routing internally
### Kubernetes Resources
```yaml
# warehouse13-app Deployment
- Replicas: 2 (configurable)
- Port: 8000
- Health checks: /health
- Environment: DATABASE_URL, MINIO_* vars
# warehouse13-app Service
- Type: ClusterIP
- Port: 8000 → 8000
# Ingress (optional)
- Path: / → warehouse13-app:8000
```
## Configuration
### Image Configuration
In `values.yaml`:
```yaml
app:
enabled: true
image:
repository: warehouse13/app # Single unified image
tag: latest
pullPolicy: IfNotPresent
replicas: 2
resources:
requests:
memory: "384Mi" # Combined frontend + backend
cpu: "350m"
limits:
memory: "768Mi"
cpu: "750m"
```
### Accessing the Application
**Via Port Forward:**
```bash
kubectl port-forward svc/warehouse13-app 8000:8000
```
Then access:
- Frontend: http://localhost:8000
- API: http://localhost:8000/api
- API Docs: http://localhost:8000/docs
- Health: http://localhost:8000/health
**Via Ingress:**
```yaml
ingress:
enabled: true
hosts:
- host: warehouse13.example.com
paths:
- path: /
pathType: Prefix
backend: app # All traffic to one service
```
## Migration from Separate Services
If you previously had separate `api` and `frontend` deployments:
### Before (Old Architecture)
```yaml
# values.yaml (old)
api:
image: warehouse13/api
replicas: 2
frontend:
image: warehouse13/frontend
replicas: 2
# Two deployments, two services
```
### After (Current Architecture)
```yaml
# values.yaml (current)
app:
image: warehouse13/app # Unified image
replicas: 2
# One deployment, one service
```
### Migration Steps
1. **Update values.yaml** - Change from `api`/`frontend` to `app`
2. **Update image references** - Use `warehouse13/app` instead of separate images
3. **Update ingress** - Point all paths to `app` backend
4. **Deploy** - Helm will handle the transition
5. **Verify** - Check that both frontend and API work through single service
## Development Workflow
### Building the Image
```bash
# Build unified image
docker build -t warehouse13/app:dev .
# Or for air-gapped environments with custom registry
docker build \
--build-arg NPM_REGISTRY=https://registry.npmjs.org/ \
-t warehouse13/app:v1.0.0 .
```
### Testing Locally
```bash
docker run -p 8000:8000 \
-e DATABASE_URL=postgresql://user:pass@host/db \
-e MINIO_ENDPOINT=minio:9000 \
warehouse13/app:dev
```
Access:
- Frontend: http://localhost:8000
- API: http://localhost:8000/docs
## Performance Considerations
### Resource Allocation
The unified container combines both frontend serving and API processing:
- **Memory**: Angular assets (~50MB) + Python runtime (~100MB) + working memory
- **CPU**: Primarily used for API requests; static file serving is lightweight
- **Recommended Minimum**: 384Mi memory, 350m CPU
- **Production**: 768Mi memory, 750m CPU per replica
### Scaling Strategy
Scale horizontally by increasing replicas:
```bash
# Scale to 5 replicas
kubectl scale deployment warehouse13-app --replicas=5
# Or via Helm
helm upgrade warehouse13 ./helm/warehouse13 --set app.replicas=5
```
### Caching
FastAPI serves static files efficiently with:
- ETag support
- Browser caching headers
- Gzip compression (if enabled in FastAPI config)
## Troubleshooting
### Frontend Not Loading
```bash
# Check if static files exist in container
kubectl exec -it warehouse13-app-xxx -- ls -la /app/static/
# Should see: index.html, *.js, *.css files
```
### API Not Working
```bash
# Check API health
kubectl exec -it warehouse13-app-xxx -- curl http://localhost:8000/health
# Check logs
kubectl logs warehouse13-app-xxx -f
```
### Both Frontend and API Issues
```bash
# Check if app is running
kubectl get pods -l app.kubernetes.io/component=app
# Check service
kubectl get svc warehouse13-app
# Test connectivity
kubectl port-forward svc/warehouse13-app 8000:8000
curl http://localhost:8000/health
```
## Summary
The unified architecture simplifies deployment and operations while maintaining the same functionality. All routing, caching, and API requests are handled by a single FastAPI application that serves both the Angular SPA and the REST API endpoints.

View File

@@ -1,16 +0,0 @@
apiVersion: v2
name: warehouse13
description: Warehouse13 - Enterprise Test Artifact Storage
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
- testing
- artifacts
- storage
- datalake
maintainers:
- name: Warehouse13 Team
home: https://github.com/yourusername/warehouse13
sources:
- https://github.com/yourusername/warehouse13

View File

@@ -1,148 +0,0 @@
# Warehouse13 Helm Chart - Quick Start
## 5-Minute Deployment
### Prerequisites Check
```bash
# Verify Kubernetes cluster access
kubectl cluster-info
# Verify Helm is installed
helm version
# Create namespace
kubectl create namespace warehouse13
```
### Deploy with Defaults
```bash
# Install chart
helm install warehouse13 ./helm/warehouse13 --namespace warehouse13
# Wait for ready
kubectl wait --for=condition=ready pod --all -n warehouse13 --timeout=5m
```
### Access Application
```bash
# In separate terminals, run:
# Terminal 1: Frontend
kubectl port-forward -n warehouse13 svc/warehouse13-frontend 4200:80
# Terminal 2: API
kubectl port-forward -n warehouse13 svc/warehouse13-api 8000:8000
# Terminal 3: MinIO Console
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9001:9001
```
Then open in browser:
- **Frontend:** http://localhost:4200
- **API Docs:** http://localhost:8000/docs
- **MinIO Console:** http://localhost:9001
- Username: `minioadmin`
- Password: `minioadmin`
## Common Scenarios
### 1. Development (No Persistence)
```bash
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--values ./helm/warehouse13/values-dev.yaml
```
### 2. Production (With Ingress)
```bash
# Update values-production.yaml with your settings first
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--values ./helm/warehouse13/values-production.yaml
```
### 3. Air-Gapped (Custom Registry)
```bash
# Update values-airgapped.yaml with your registry first
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--values ./helm/warehouse13/values-airgapped.yaml
```
### 4. Custom Image Repository
```bash
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--set postgres.image.repository=myregistry.com/postgres \
--set minio.image.repository=myregistry.com/minio \
--set api.image.repository=myregistry.com/warehouse13-api \
--set frontend.image.repository=myregistry.com/warehouse13-frontend
```
## Verify Deployment
```bash
# Check pods
kubectl get pods -n warehouse13
# Check services
kubectl get svc -n warehouse13
# View logs
kubectl logs -n warehouse13 -l app.kubernetes.io/component=api --tail=50
# Check resource usage
kubectl top pods -n warehouse13
```
## Cleanup
```bash
# Uninstall release
helm uninstall warehouse13 -n warehouse13
# Delete PVCs (data will be lost!)
kubectl delete pvc -n warehouse13 --all
# Delete namespace
kubectl delete namespace warehouse13
```
## Next Steps
- **Full Documentation:** [README.md](./README.md)
- **Deployment Guide:** [../../docs/HELM-DEPLOYMENT.md](../../docs/HELM-DEPLOYMENT.md)
- **Configuration Options:** [values.yaml](./values.yaml)
- **Example Configs:** [values-dev.yaml](./values-dev.yaml), [values-production.yaml](./values-production.yaml), [values-airgapped.yaml](./values-airgapped.yaml)
## Troubleshooting
### Pods stuck in Pending
```bash
kubectl describe pod <pod-name> -n warehouse13
# Check: PVC status, node resources, storage classes
```
### Image pull errors
```bash
kubectl describe pod <pod-name> -n warehouse13
# Check: Image repository, credentials, network access
```
### Database connection errors
```bash
kubectl logs -n warehouse13 warehouse13-postgres-0
kubectl get secret -n warehouse13 warehouse13-secrets -o yaml
```
## Support
- GitHub Issues: https://github.com/yourusername/warehouse13/issues
- Documentation: https://warehouse13.example.com/docs

View File

@@ -1,441 +0,0 @@
# Warehouse13 Helm Chart
Enterprise Test Artifact Storage - Kubernetes deployment via Helm
## Overview
This Helm chart deploys the complete Warehouse13 stack on Kubernetes:
- **PostgreSQL 15** - Metadata database
- **MinIO** - S3-compatible object storage
- **FastAPI Backend** - REST API server
- **Angular Frontend** - Web UI (nginx-served)
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- PV provisioner support (for persistent storage)
## Installation
### Quick Start
```bash
# Add the Warehouse13 chart repository (if published)
helm repo add warehouse13 https://charts.warehouse13.example.com
helm repo update
# Install with default values
helm install my-warehouse13 warehouse13/warehouse13
# Or install from local chart
helm install my-warehouse13 ./helm/warehouse13
```
### Custom Installation
```bash
# Install with custom values
helm install my-warehouse13 ./helm/warehouse13 \
--set postgres.persistence.size=20Gi \
--set minio.persistence.size=100Gi \
--set api.replicas=3
# Install in a specific namespace
helm install my-warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--create-namespace
```
## Configuration
### Configurable Images
All component images can be customized via values.yaml or command-line flags:
```yaml
postgres:
image:
repository: postgres
tag: 15-alpine
pullPolicy: IfNotPresent
minio:
image:
repository: minio/minio
tag: latest
pullPolicy: IfNotPresent
api:
image:
repository: warehouse13/api
tag: latest
pullPolicy: IfNotPresent
frontend:
image:
repository: warehouse13/frontend
tag: latest
pullPolicy: IfNotPresent
```
**Example: Using custom image registry**
```bash
helm install my-warehouse13 ./helm/warehouse13 \
--set postgres.image.repository=myregistry.example.com/postgres \
--set minio.image.repository=myregistry.example.com/minio \
--set api.image.repository=myregistry.example.com/warehouse13-api \
--set frontend.image.repository=myregistry.example.com/warehouse13-frontend
```
**Example: Air-gapped deployment with specific tags**
```bash
helm install my-warehouse13 ./helm/warehouse13 \
--set postgres.image.repository=harbor.internal/library/postgres \
--set postgres.image.tag=15-alpine \
--set minio.image.repository=harbor.internal/library/minio \
--set minio.image.tag=RELEASE.2024-01-01T00-00-00Z \
--set api.image.repository=harbor.internal/warehouse13/api \
--set api.image.tag=v1.0.0 \
--set frontend.image.repository=harbor.internal/warehouse13/frontend \
--set frontend.image.tag=v1.0.0
```
### Key Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| `global.deploymentMode` | Deployment mode (standard/airgapped) | `standard` |
| `global.storageBackend` | Storage backend (minio/s3) | `minio` |
| `postgres.persistence.enabled` | Enable PostgreSQL persistence | `true` |
| `postgres.persistence.size` | PostgreSQL PVC size | `10Gi` |
| `postgres.auth.username` | PostgreSQL username | `user` |
| `postgres.auth.password` | PostgreSQL password | `password` |
| `minio.persistence.enabled` | Enable MinIO persistence | `true` |
| `minio.persistence.size` | MinIO PVC size | `50Gi` |
| `minio.auth.rootUser` | MinIO root username | `minioadmin` |
| `minio.auth.rootPassword` | MinIO root password | `minioadmin` |
| `api.replicas` | Number of API replicas | `2` |
| `frontend.replicas` | Number of frontend replicas | `2` |
| `ingress.enabled` | Enable ingress | `false` |
| `ingress.className` | Ingress class name | `nginx` |
| `ingress.hosts` | Ingress hosts configuration | See values.yaml |
### Example Configurations
#### Production with Ingress
```yaml
# values-production.yaml
global:
deploymentMode: "standard"
storageBackend: "minio"
postgres:
persistence:
size: 50Gi
storageClass: "fast-ssd"
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
minio:
persistence:
size: 500Gi
storageClass: "bulk-storage"
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
api:
replicas: 3
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
frontend:
replicas: 3
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: warehouse13.example.com
paths:
- path: /
pathType: Prefix
backend: frontend
- path: /api
pathType: Prefix
backend: api
tls:
- secretName: warehouse13-tls
hosts:
- warehouse13.example.com
```
```bash
helm install my-warehouse13 ./helm/warehouse13 -f values-production.yaml
```
#### Air-Gapped Environment
```yaml
# values-airgapped.yaml
global:
deploymentMode: "airgapped"
storageBackend: "minio"
postgres:
image:
repository: harbor.internal.example.com/library/postgres
tag: 15-alpine
pullPolicy: IfNotPresent
minio:
image:
repository: harbor.internal.example.com/library/minio
tag: RELEASE.2024-01-01T00-00-00Z
pullPolicy: IfNotPresent
api:
image:
repository: harbor.internal.example.com/warehouse13/api
tag: v1.0.0
pullPolicy: IfNotPresent
frontend:
image:
repository: harbor.internal.example.com/warehouse13/frontend
tag: v1.0.0
pullPolicy: IfNotPresent
```
```bash
helm install my-warehouse13 ./helm/warehouse13 -f values-airgapped.yaml
```
#### Development/Testing
```yaml
# values-dev.yaml
global:
deploymentMode: "standard"
postgres:
persistence:
enabled: false # Use emptyDir for quick testing
resources:
requests:
memory: "128Mi"
cpu: "100m"
minio:
persistence:
enabled: false
resources:
requests:
memory: "256Mi"
cpu: "100m"
api:
replicas: 1
image:
tag: dev
frontend:
replicas: 1
image:
tag: dev
```
```bash
helm install my-warehouse13 ./helm/warehouse13 -f values-dev.yaml
```
## Accessing the Application
### Port Forwarding (Development)
```bash
# Access frontend
kubectl port-forward svc/warehouse13-frontend 4200:80
# Access API
kubectl port-forward svc/warehouse13-api 8000:8000
# Access MinIO console
kubectl port-forward svc/warehouse13-minio 9001:9001
# Then visit:
# - Frontend: http://localhost:4200
# - API: http://localhost:8000
# - MinIO Console: http://localhost:9001
```
### Via Ingress (Production)
If ingress is enabled:
```
https://warehouse13.example.com
```
## Upgrading
```bash
# Upgrade with new values
helm upgrade my-warehouse13 ./helm/warehouse13 \
--set api.image.tag=v2.0.0 \
--set frontend.image.tag=v2.0.0
# Upgrade with values file
helm upgrade my-warehouse13 ./helm/warehouse13 -f values-production.yaml
# Upgrade and wait for completion
helm upgrade my-warehouse13 ./helm/warehouse13 --wait --timeout 10m
```
## Uninstalling
```bash
# Uninstall the release
helm uninstall my-warehouse13
# Note: PVCs are not deleted automatically. To delete them:
kubectl delete pvc -l app.kubernetes.io/instance=my-warehouse13
```
## Backup and Restore
### PostgreSQL Backup
```bash
# Create backup
kubectl exec -it warehouse13-postgres-0 -- pg_dump -U user warehouse13 > backup.sql
# Restore
kubectl exec -i warehouse13-postgres-0 -- psql -U user warehouse13 < backup.sql
```
### MinIO Backup
```bash
# Install mc (MinIO Client)
# Configure mc alias
mc alias set w13 http://localhost:9001 minioadmin minioadmin
# Mirror bucket
mc mirror w13/artifacts ./backup/artifacts
# Restore
mc mirror ./backup/artifacts w13/artifacts
```
## Troubleshooting
### Check Pod Status
```bash
kubectl get pods -l app.kubernetes.io/name=warehouse13
```
### View Logs
```bash
# API logs
kubectl logs -l app.kubernetes.io/component=api -f
# Frontend logs
kubectl logs -l app.kubernetes.io/component=frontend -f
# PostgreSQL logs
kubectl logs warehouse13-postgres-0 -f
# MinIO logs
kubectl logs warehouse13-minio-0 -f
```
### Check Services
```bash
kubectl get svc -l app.kubernetes.io/name=warehouse13
```
### Common Issues
**Pods stuck in Pending**
- Check PVC status: `kubectl get pvc`
- Verify storage class exists: `kubectl get storageclass`
- Check node resources: `kubectl describe nodes`
**Database connection errors**
- Verify postgres pod is running: `kubectl get pod warehouse13-postgres-0`
- Check database logs: `kubectl logs warehouse13-postgres-0`
- Verify secret exists: `kubectl get secret warehouse13-secrets`
**Frontend cannot reach API**
- Check ingress configuration: `kubectl describe ingress warehouse13-ingress`
- Verify API service: `kubectl get svc warehouse13-api`
- Check API pod health: `kubectl get pods -l app.kubernetes.io/component=api`
## Security Considerations
### Secrets Management
**Default credentials are for development only!** In production:
1. **Use external secrets management:**
```yaml
# Use sealed-secrets, external-secrets, or similar
postgres:
auth:
username: "{{ .Values.externalSecrets.postgresUser }}"
password: "{{ .Values.externalSecrets.postgresPassword }}"
```
2. **Or create secrets manually:**
```bash
kubectl create secret generic warehouse13-secrets \
--from-literal=postgres-username=secure-user \
--from-literal=postgres-password=secure-password \
--from-literal=minio-root-user=secure-minio-user \
--from-literal=minio-root-password=secure-minio-password
# Then install without default secrets
helm install my-warehouse13 ./helm/warehouse13 --set createSecrets=false
```
3. **Enable TLS:**
```yaml
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- secretName: warehouse13-tls
hosts:
- warehouse13.example.com
```
## Support
For issues and questions:
- GitHub Issues: https://github.com/yourusername/warehouse13/issues
- Documentation: https://warehouse13.example.com/docs

View File

@@ -1,131 +0,0 @@
_ _ _ _ _____
| | | | | | / |___ /
| | | | __ _ _ __ ___| |__ ___ _ _ ___ / / |_ \
| |/\| |/ _` | '__/ _ \ '_ \ / _ \| | | / __|/ / ___) |
\ /\ / (_| | | | __/ | | | (_) | |_| \__ \_/ |____/
\/ \/ \__,_|_| \___|_| |_|\___/ \__,_|___(_)
Enterprise Test Artifact Storage has been deployed!
Chart Name: {{ .Chart.Name }}
Chart Version: {{ .Chart.Version }}
App Version: {{ .Chart.AppVersion }}
Release Name: {{ .Release.Name }}
Namespace: {{ .Release.Namespace }}
---
DEPLOYMENT INFORMATION:
{{- if .Values.app.enabled }}
Application (Unified API + Frontend):
Service: warehouse13-app
Replicas: {{ .Values.app.replicas }}
Image: {{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}
Port: {{ .Values.app.service.port }}
Note: Multi-stage build includes both Angular frontend and FastAPI backend
{{- end }}
{{- if .Values.postgres.enabled }}
PostgreSQL:
Service: warehouse13-postgres
Image: {{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}
Persistence: {{ if .Values.postgres.persistence.enabled }}Enabled ({{ .Values.postgres.persistence.size }}){{ else }}Disabled (emptyDir){{ end }}
{{- end }}
{{- if .Values.minio.enabled }}
MinIO:
Service: warehouse13-minio
Image: {{ .Values.minio.image.repository }}:{{ .Values.minio.image.tag }}
Persistence: {{ if .Values.minio.persistence.enabled }}Enabled ({{ .Values.minio.persistence.size }}){{ else }}Disabled (emptyDir){{ end }}
{{- end }}
---
ACCESSING YOUR APPLICATION:
{{- if .Values.ingress.enabled }}
1. Via Ingress:
{{- range .Values.ingress.hosts }}
https://{{ .host }}
{{- end }}
{{- else }}
1. Using Port Forwarding:
# Application (Frontend + API)
kubectl port-forward -n {{ .Release.Namespace }} svc/warehouse13-app 8000:8000
Then visit:
- Frontend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Health: http://localhost:8000/health
# MinIO Console
kubectl port-forward -n {{ .Release.Namespace }} svc/warehouse13-minio 9001:9001
Then visit: http://localhost:9001
Username: {{ .Values.minio.auth.rootUser }}
Password: {{ .Values.minio.auth.rootPassword }}
2. Expose via LoadBalancer or Ingress for external access.
{{- end }}
---
CHECKING STATUS:
# View all pods
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
# Check services
kubectl get svc -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
# View logs
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/component=app -f
---
UPGRADING:
helm upgrade {{ .Release.Name }} warehouse13/warehouse13 \
--namespace {{ .Release.Namespace }}
---
UNINSTALLING:
helm uninstall {{ .Release.Name }} --namespace {{ .Release.Namespace }}
# Note: PVCs are retained. To delete them:
kubectl delete pvc -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
---
{{- if not .Values.ingress.enabled }}
⚠️ IMPORTANT: Ingress is disabled. Enable it for production use:
--set ingress.enabled=true
{{- end }}
{{- if eq .Values.postgres.auth.password "password" }}
⚠️ WARNING: Using default PostgreSQL password!
For production, set a secure password:
--set postgres.auth.password=YOUR_SECURE_PASSWORD
{{- end }}
{{- if eq .Values.minio.auth.rootPassword "minioadmin" }}
⚠️ WARNING: Using default MinIO password!
For production, set a secure password:
--set minio.auth.rootPassword=YOUR_SECURE_PASSWORD
{{- end }}
---
For more information, visit:
Documentation: https://github.com/yourusername/warehouse13
Issues: https://github.com/yourusername/warehouse13/issues
Thank you for using Warehouse13!

View File

@@ -1,97 +0,0 @@
{{- if .Values.app.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: warehouse13-app
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
app.kubernetes.io/component: app
spec:
replicas: {{ .Values.app.replicas }}
selector:
matchLabels:
{{- include "warehouse13.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: app
template:
metadata:
labels:
{{- include "warehouse13.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: app
spec:
serviceAccountName: {{ include "warehouse13.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: app
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}"
imagePullPolicy: {{ .Values.app.image.pullPolicy }}
ports:
- name: http
containerPort: 8000
protocol: TCP
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: database-url
- name: STORAGE_BACKEND
valueFrom:
configMapKeyRef:
name: warehouse13-config
key: STORAGE_BACKEND
- name: MINIO_ENDPOINT
valueFrom:
configMapKeyRef:
name: warehouse13-config
key: MINIO_ENDPOINT
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: minio-root-user
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: minio-root-password
- name: MINIO_BUCKET_NAME
value: "test-artifacts"
- name: MINIO_SECURE
value: "false"
- name: DEPLOYMENT_MODE
valueFrom:
configMapKeyRef:
name: warehouse13-config
key: DEPLOYMENT_MODE
resources:
{{- toYaml .Values.app.resources | nindent 10 }}
{{- if .Values.app.healthCheck.enabled }}
livenessProbe:
httpGet:
path: {{ .Values.app.healthCheck.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.app.healthCheck.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.app.healthCheck.liveness.periodSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.app.healthCheck.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.app.healthCheck.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.app.healthCheck.readiness.periodSeconds }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.app.enabled }}
apiVersion: v1
kind: Service
metadata:
name: warehouse13-app
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
app.kubernetes.io/component: app
spec:
type: {{ .Values.app.service.type }}
ports:
- port: {{ .Values.app.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "warehouse13.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: app
{{- end }}

View File

@@ -1,10 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: warehouse13-config
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
data:
DEPLOYMENT_MODE: {{ .Values.global.deploymentMode | quote }}
STORAGE_BACKEND: {{ .Values.global.storageBackend | quote }}
MINIO_ENDPOINT: {{ printf "warehouse13-minio:%d" (.Values.minio.service.apiPort | int) | quote }}

View File

@@ -1,23 +0,0 @@
{{- if .Values.minio.enabled }}
apiVersion: v1
kind: Service
metadata:
name: warehouse13-minio
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
type: {{ .Values.minio.service.type }}
ports:
- port: {{ .Values.minio.service.apiPort }}
targetPort: api
protocol: TCP
name: api
- port: {{ .Values.minio.service.consolePort }}
targetPort: console
protocol: TCP
name: console
selector:
{{- include "warehouse13.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: storage
{{- end }}

View File

@@ -1,87 +0,0 @@
{{- if .Values.minio.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: warehouse13-minio
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
serviceName: warehouse13-minio
replicas: 1
selector:
matchLabels:
{{- include "warehouse13.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: storage
template:
metadata:
labels:
{{- include "warehouse13.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: storage
spec:
serviceAccountName: {{ include "warehouse13.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: minio
image: "{{ .Values.minio.image.repository }}:{{ .Values.minio.image.tag }}"
imagePullPolicy: {{ .Values.minio.image.pullPolicy }}
command:
- minio
- server
- /data
- --console-address
- ":9001"
ports:
- name: api
containerPort: 9000
protocol: TCP
- name: console
containerPort: 9001
protocol: TCP
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: minio-root-user
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: minio-root-password
volumeMounts:
- name: data
mountPath: /data
resources:
{{- toYaml .Values.minio.resources | nindent 10 }}
livenessProbe:
httpGet:
path: /minio/health/live
port: api
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /minio/health/ready
port: api
initialDelaySeconds: 10
periodSeconds: 5
{{- if .Values.minio.persistence.enabled }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
{{- if .Values.minio.persistence.storageClass }}
storageClassName: {{ .Values.minio.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.minio.persistence.size }}
{{- else }}
volumes:
- name: data
emptyDir: {}
{{- end }}
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.postgres.enabled }}
apiVersion: v1
kind: Service
metadata:
name: warehouse13-postgres
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
type: {{ .Values.postgres.service.type }}
ports:
- port: {{ .Values.postgres.service.port }}
targetPort: postgres
protocol: TCP
name: postgres
selector:
{{- include "warehouse13.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: database
{{- end }}

View File

@@ -1,89 +0,0 @@
{{- if .Values.postgres.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: warehouse13-postgres
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
serviceName: warehouse13-postgres
replicas: 1
selector:
matchLabels:
{{- include "warehouse13.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: database
template:
metadata:
labels:
{{- include "warehouse13.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: database
spec:
serviceAccountName: {{ include "warehouse13.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: postgres
image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}"
imagePullPolicy: {{ .Values.postgres.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: postgres-username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: postgres-password
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: warehouse13-secrets
key: postgres-database
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
resources:
{{- toYaml .Values.postgres.resources | nindent 10 }}
livenessProbe:
exec:
command:
- pg_isready
- -U
- $(POSTGRES_USER)
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- $(POSTGRES_USER)
initialDelaySeconds: 10
periodSeconds: 5
{{- if .Values.postgres.persistence.enabled }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
{{- if .Values.postgres.persistence.storageClass }}
storageClassName: {{ .Values.postgres.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.persistence.size }}
{{- else }}
volumes:
- name: data
emptyDir: {}
{{- end }}
{{- end }}

Some files were not shown because too many files have changed in this diff Show More