Compare commits

..

7 Commits

Author SHA1 Message Date
Patel (US), Pratik
eccdb2f072 Add BSF package lock 2025-10-15 12:26:07 -05:00
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
128 changed files with 35506 additions and 6929 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__
*.pyc
*.pyo
@@ -5,15 +6,50 @@ __pycache__
.Python
env/
venv/
# Node modules (critical for fixing esbuild platform issue)
node_modules
frontend/node_modules
*/node_modules
# Build outputs
frontend/dist
frontend/.angular
# Environment variables
*.env
.env
# Git
.git
.gitignore
# Documentation
*.md
# IDE files
.vscode
.idea
# Logs
*.log
# OS files
.DS_Store
# Configuration
helm/
.gitlab-ci.yml
docker-compose.yml
# Development files
frontend/.vscode
frontend/src/**/*.spec.ts
# Runtime data
*.pid
*.seed
*.pid.lock
# Coverage
coverage

View File

@@ -27,9 +27,3 @@ MINIO_SECURE=false
API_HOST=0.0.0.0
API_PORT=8000
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/

View File

@@ -1,26 +0,0 @@
name: build
on:
push:
branches:
- main
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: git.bitstorm.ca # e.g., docker.io for Docker Hub
username: ${{ secrets.REGISTRY_LOGIN }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: git.bitstorm.ca/bitforge/warehouse13:latest

8
.gitignore vendored
View File

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

View File

@@ -1,42 +1,164 @@
stages:
- test
- build
- deploy
build_container:
stage: build
image: deps.global.bsf.tools/quay.io/buildah/stable:latest
variables:
IMAGE_NAME: "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
before_script:
- mkdir -p /tmp/buildah-storage
- export BUILDAH_ROOT="/tmp/buildah-storage"
- echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
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 .
- buildah pull git.bitstorm.ca/bitforge/warehouse13:latest
- buildah tag git.bitstorm.ca/bitforge/warehouse13:latest $IMAGE_NAME
- echo "Pushing $IMAGE_NAME"
- buildah push $IMAGE_NAME
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
LATEST_TAG: $CI_REGISTRY_IMAGE:latest
deploy_helm_charts:
stage: deploy
image:
name: deps.global.bsf.tools/registry-1.docker.io/alpine/k8s:1.29.12
parallel:
matrix:
# - 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"]
# 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:
- kubectl config use-context $CONTEXT
- echo "Deploy - $CI_REGISTRY_NAME - $CI_COMMIT_REF_SLUG - $CI_COMMIT_SHORT_SHA"
- 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 warehouse13-dev ./helm/warehouse13 --namespace $NAMESPACE -f $VALUES_FILE --set app.image.repository=$CI_REGISTRY_IMAGE --set app.image.tag=$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA
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,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:20.11-alpine3.19 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
WORKDIR /app
@@ -48,9 +21,6 @@ COPY utils/ ./utils/
COPY alembic/ ./alembic/
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)
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
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**
@@ -36,32 +36,18 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
## Quick Start
### Standard Deployment (Internet Access)
### One-Command Setup
**Linux/macOS:**
```bash
./quickstart.sh
./scripts/quickstart.sh
```
**Windows (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
1. Clone the repository:
@@ -214,54 +200,35 @@ MINIO_BUCKET_NAME=test-artifacts
### Kubernetes with Helm
**Quick Start:**
1. Build and push Docker image:
```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
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--create-namespace \
--values ./helm/warehouse13/values-production.yaml
helm install datalake ./helm \
--set image.repository=your-registry/datalake \
--set image.tag=latest \
--namespace datalake \
--create-namespace
```
**Air-Gapped Deployment:**
3. Access the API:
```bash
helm install warehouse13 ./helm/warehouse13 \
--namespace warehouse13 \
--create-namespace \
--values ./helm/warehouse13/values-airgapped.yaml
kubectl port-forward -n datalake svc/datalake 8000:8000
```
**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
All component images are fully configurable in `helm/warehouse13/values.yaml`:
- PostgreSQL image and version
- MinIO image and version
- API image and version
- Frontend image and version
- Resource limits and requests
- Storage backend configuration
- Ingress and TLS settings
- Persistence and storage classes
Edit `helm/values.yaml` to customize:
- Replica count
- Resource limits
- Storage backend (S3 vs MinIO)
- Ingress settings
- PostgreSQL settings
- Autoscaling
### GitLab CI/CD
@@ -336,6 +303,26 @@ alembic upgrade head
- Verify `MINIO_ENDPOINT` is correct
- Check MinIO credentials
## Documentation
Detailed documentation is available in the `docs/` folder:
- **[Quick Start Guide](docs/QUICKSTART.md)** - Get started in minutes
- **[API Documentation](docs/API.md)** - Complete API reference
- **[Architecture](docs/ARCHITECTURE.md)** - System design and architecture
- **[Features](docs/FEATURES.md)** - Detailed feature descriptions
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Production deployment instructions
- **[Frontend Setup](docs/FRONTEND_SETUP.md)** - Angular frontend setup
- **[Frontend Usage](docs/FRONTEND_USAGE.md)** - Using the web UI
## Scripts
Helper scripts are available in the `scripts/` folder:
- **`quickstart.sh` / `quickstart.ps1`** - Quick start with Docker Compose
- **`quickstart-build.sh`** - Quick start with image rebuild
- **`dev-start.sh` / `dev-start.ps1`** - Start development environment
## License
[Your License Here]

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

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

View File

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

View File

@@ -1,9 +1,8 @@
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.api.artifacts import router as artifacts_router
from app.api.seed import router as seed_router
from app.api.tags import router as tags_router
from app.database import init_db
from app.config import settings
import logging
@@ -19,8 +18,8 @@ logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="Warehouse13",
description="Enterprise Test Artifact Storage - API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
title="Test Artifact Data Lake",
description="API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
@@ -38,9 +37,9 @@ app.add_middleware(
# Include routers
app.include_router(artifacts_router)
app.include_router(seed_router)
app.include_router(tags_router)
# Static directory setup
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
# Note: Frontend is now served separately as an Angular application
@app.on_event("startup")
@@ -57,7 +56,7 @@ async def startup_event():
async def api_root():
"""API root endpoint"""
return {
"message": "Warehouse13 - Enterprise Test Artifact Storage",
"message": "Test Artifact Data Lake API",
"version": "1.0.0",
"docs": "/docs",
"deployment_mode": settings.deployment_mode,
@@ -67,40 +66,12 @@ async def api_root():
@app.get("/")
async def ui_root():
"""Serve the UI"""
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
else:
"""API root - Frontend is served separately"""
return {
"message": "Warehouse13 - Enterprise Test Artifact Storage",
"message": "Test Artifact Data Lake API",
"version": "1.0.0",
"docs": "/docs",
"ui": "UI not found. Serving API only.",
"deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend
}
# 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.",
"frontend": "Frontend is served separately on port 4200 (development) or via reverse proxy (production)",
"deployment_mode": settings.deployment_mode,
"storage_backend": settings.storage_backend
}

View File

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

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_config: Optional[Dict[str, Any]] = None
test_result: Optional[str] = None
sim_source_id: Optional[str] = None # Groups artifacts from same SIM source
sim_source_id: Optional[str] = None
custom_metadata: Optional[Dict[str, Any]] = None
description: Optional[str] = None
tags: Optional[List[str]] = None

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

View File

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

View File

@@ -1,220 +0,0 @@
# NPM Package Age Policy
## Requirement
All npm packages must be **at least 2 weeks old** before they can be used in this project. This ensures:
- Package stability
- Security vulnerability disclosure time
- Compliance with organizational security policies
## How It Works
### 1. Package Version Pinning
The project uses exact version pinning in `package.json` to prevent automatic updates:
```json
{
"dependencies": {
"@angular/core": "19.2.7", // Exact version, not "^19.2.7" or "~19.2.7"
// ...
}
}
```
The `frontend/.npmrc` file enforces this:
```
save-exact=true
package-lock=true
```
### 2. Automated Age Checking
Use the provided script to verify all packages meet the 2-week requirement:
```bash
# Check if all packages are at least 2 weeks old
node scripts/check-package-age.js
```
This script:
- Queries npm registry for publish dates
- Calculates age of each package
- Fails if any package is newer than 14 days
- Shows detailed age information for all packages
### 3. Installation Process
**Always use `npm ci` instead of `npm install`:**
```bash
cd frontend
npm ci # Installs exact versions from package-lock.json
```
**Why `npm ci`?**
- Uses exact versions from `package-lock.json`
- Doesn't update `package-lock.json`
- Ensures reproducible builds
- Faster than `npm install`
## Updating Packages
When you need to add or update packages:
### Step 1: Add Package to package.json
```bash
# Find a version that's at least 2 weeks old
npm view <package-name> time
# Add exact version to package.json
"dependencies": {
"new-package": "1.2.3"
}
```
### Step 2: Verify Age
```bash
node scripts/check-package-age.js
```
### Step 3: Update Lock File
```bash
cd frontend
npm install --package-lock-only
```
### Step 4: Install and Test
```bash
npm ci
npm run build:prod
```
## CI/CD Integration
Add the age check to your CI/CD pipeline:
### GitLab CI Example
```yaml
verify_package_age:
stage: validate
image: node:18-alpine
script:
- node scripts/check-package-age.js
only:
- merge_requests
- main
```
### GitHub Actions Example
```yaml
- name: Check Package Age
run: node scripts/check-package-age.js
```
## Troubleshooting
### "Package is too new" Error
If a package fails the age check:
1. **Find an older version:**
```bash
npm view <package-name> versions --json
npm view <package-name>@<older-version> time
```
2. **Update package.json with older version**
3. **Re-run age check:**
```bash
node scripts/check-package-age.js
```
### Can't Find Old Enough Version
If no version meets the 2-week requirement:
- Wait until the package is at least 2 weeks old
- Look for alternative packages
- Request an exception through your security team
## Example Workflow
```bash
# 1. Check current package ages
node scripts/check-package-age.js
# 2. If all pass, install dependencies
cd frontend
npm ci
# 3. Build application
npm run build:prod
# 4. For air-gapped deployment
../scripts/build-for-airgap.sh
```
## Scripts Reference
| Script | Purpose |
|--------|---------|
| `scripts/check-package-age.js` | Verify all packages are ≥ 2 weeks old |
| `scripts/pin-old-versions.sh` | Helper script to validate and pin versions |
| `scripts/build-for-airgap.sh` | Build frontend for air-gapped deployment |
## Best Practices
1. **Always commit `package-lock.json`**
- Ensures everyone uses the same versions
- Required for reproducible builds
2. **Use `npm ci` in CI/CD**
- Faster than `npm install`
- Enforces lock file versions
- Prevents surprises
3. **Regular audits**
```bash
# Check for security vulnerabilities
npm audit
# Check package ages
node scripts/check-package-age.js
```
4. **Version ranges to avoid**
- ❌ `^1.2.3` (allows minor/patch updates)
- ❌ `~1.2.3` (allows patch updates)
- ❌ `*` or `latest` (allows any version)
- ✅ `1.2.3` (exact version only)
## Package Age Check Output
```
Checking package ages (must be at least 2 weeks old)...
✓ @angular/common@19.2.7 - 45 days old
✓ @angular/compiler@19.2.7 - 45 days old
✓ rxjs@7.8.0 - 180 days old
❌ new-package@1.0.0 - 5 days old (published 2025-01-12)
================================================================================
❌ FAILED: 1 package(s) are newer than 2 weeks:
- new-package@1.0.0 (5 days old, published 2025-01-12)
```
## Support
For questions or exceptions:
- Review with security team
- Document in project README
- Update this policy as needed

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
# Or directly with Helm
helm install warehouse13 ./helm/warehouse13 --namespace warehouse13 --create-namespace
helm install datalake ./helm --namespace datalake --create-namespace
```
## Feature Flags Usage
@@ -190,8 +190,9 @@ AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket
# Deploy
helm install warehouse13 ./helm/warehouse13 \
--set global.deploymentMode=cloud
helm install datalake ./helm \
--set config.deploymentMode=cloud \
--set aws.enabled=true
```
## What's Next

1
frontend/.gitignore vendored
View File

@@ -36,7 +36,6 @@ yarn-error.log
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.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
- **Angular 20** with standalone components
- **TypeScript** for type safety
- **Reactive Forms** for upload and query functionality
- **RxJS** for reactive programming
- **Auto-refresh** artifacts every 5 seconds
- **Client-side sorting and filtering**
- **Dark theme** UI
- **Responsive design**
**Multi-component Architecture**: Built with reusable Angular components
**Tab Navigation**: Clean tab-based interface for Artifacts, Upload, and Query
**Event ID Support**: Group multiple artifacts under the same event ID
**Expandable Binaries Display**: Show first 4 binaries, expandable for more
**Advanced Tag Management**: Create tags on-the-spot with database persistence
**Scoped Tags**: Organize tags by scope (project, environment, priority, etc.)
**Comprehensive Filtering**: Filter artifacts by all table criteria
**Real-time Search**: As-you-type filtering in query form
**Responsive Design**: Mobile-friendly interface
## Components
### Core Components
- **TabNavigationComponent**: Manages tab switching between Artifacts, Upload, and Query
- **ArtifactsTableComponent**: Displays artifacts with expandable binaries/tags and Event ID support
- **UploadFormComponent**: File upload with Event ID and binaries support
- **QueryFormComponent**: Advanced search with real-time filtering
- **TagManagerComponent**: On-the-spot tag creation with scoped tags
### Services
- **ArtifactService**: Handles all artifact-related API calls
- **ApiService**: Manages general API information
## Development
### Prerequisites
- Node.js (v18 or later)
- Angular CLI 19
- Node.js 24.x or higher
- npm 11.x or higher
- Backend API running on port 8000
### Installation
### Setup
```bash
cd frontend
npm install
```
### Run Development Server
### Development Server
```bash
npm start
# or
ng serve
```
The application will be available at `http://localhost:4200/`
The development server includes a proxy configuration that forwards `/api` requests to `http://localhost:8000`.
The app will be available at `http://localhost:4200`
### Build for Production
```bash
npm run build:prod
npm run build
# or
ng build
```
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
Built files will be in `dist/frontend/`
## 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
- `GET /api/v1/artifacts/:id` - Get single artifact
- `POST /api/v1/artifacts/upload` - Upload new artifact
- `POST /api/v1/artifacts/query` - Query with filters
- `GET /api/v1/artifacts/:id/download` - Download artifact file
- `DELETE /api/v1/artifacts/:id` - Delete artifact
- `POST /api/v1/seed/generate/:count` - Generate seed data
### Required API Endpoints
- `GET /api` - API information
- `GET /api/v1/artifacts/` - List artifacts
- `GET /api/v1/artifacts/{id}` - Get artifact details
- `POST /api/v1/artifacts/upload` - Upload artifact
- `DELETE /api/v1/artifacts/{id}` - Delete artifact
- `GET /api/v1/artifacts/{id}/download` - Download artifact
- `POST /api/v1/artifacts/query` - Query artifacts
- `POST /api/v1/seed/generate/{count}` - Generate seed data
- `GET /api/v1/tags` - List all tags
- `POST /api/v1/tags` - Create tag
- `POST /api/v1/artifacts/{id}/tags` - Add tag to artifact
- `DELETE /api/v1/artifacts/{id}/tags/{tag_id}` - Remove tag from artifact
## 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
{
"/api": {
"target": "http://localhost:8000",
"secure": false,
"changeOrigin": true
}
}
```
### 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.
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:
- Dark blue/slate color palette
- Gradient headers
- Responsive design
- Smooth transitions and hover effects
- Tag badges for categorization
- Result badges for test statuses
Filters are applied immediately as you type, and active filters are displayed as visual chips.
## Browser Support
## Architecture Improvements
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
### From Static to Angular
The original static JavaScript implementation has been converted to:
## 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
2. **Type Safety**: Use TypeScript interfaces in `models/` for all API responses
3. **State Management**: Currently using component-level state; consider NgRx for complex state
4. **Testing**: Run `npm test` for unit tests (Jasmine/Karma)
### Benefits
- **Maintainability**: Clear separation of concerns
- **Reusability**: Components can be reused and extended
- **Testing**: Angular's testing framework support
- **Performance**: Optimized change detection and lazy loading
- **Developer Experience**: Hot reload, TypeScript, and Angular DevTools
## Deployment
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
server {
listen 80;
server_name your-domain.com;
root /path/to/dist/frontend/browser;
### Integration with Existing Backend
The Angular frontend is designed to be a drop-in replacement for the static frontend. Simply:
location / {
try_files $uri $uri/ /index.html;
}
1. Build the Angular app: `npm run build`
2. Copy contents of `dist/frontend/` to your static files directory
3. Update your backend to serve the new `index.html`
4. Ensure API endpoints match the expected interface
location /api {
proxy_pass http://backend:8000;
}
}
```
## Browser Support
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
## Future Enhancements
- [ ] Add NgRx for state management
- [ ] Implement WebSocket for real-time updates
- [ ] Add Angular Material components
- [ ] Unit and E2E tests
- [ ] PWA support
- [ ] Drag-and-drop file upload
- [ ] Bulk operations
- [ ] Export to CSV/JSON
## License
Same as parent project
- Drag and drop file upload
- Bulk operations
- Advanced data visualization
- Real-time updates via WebSocket
- Export functionality
- User authentication integration

View File

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

13838
frontend/package-lock-bsf.json Normal file

File diff suppressed because it is too large Load Diff

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",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --configuration production",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "19.2.x",
"@angular/compiler": "19.2.x",
"@angular/core": "19.2.x",
"@angular/forms": "19.2.x",
"@angular/platform-browser": "19.2.x",
"@angular/router": "19.2.x",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.19",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.8.1",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "19.2.x",
"@angular/cli": "19.2.x",
"@angular/compiler-cli": "19.2.x",
"@angular-devkit/build-angular": "^19.2.17",
"@angular/cli": "^19.2.17",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "5.x.x",
"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"
"typescript": "~5.7.2"
}
}

View File

@@ -2,6 +2,10 @@
"/api": {
"target": "http://localhost:8000",
"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 { App } from './app';
import { AppComponent } from './app.component';
describe('App', () => {
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'frontend' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');

View File

@@ -0,0 +1,215 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTooltipModule } from '@angular/material/tooltip';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QueryFormComponent } from './components/query-form/query-form.component';
import { ApiService } from './services/api.service';
import { ArtifactService } from './services/artifact.service';
import { ApiInfo, Artifact } from './models/artifact.interface';
@Component({
selector: 'app-root',
imports: [
CommonModule,
FormsModule,
MatToolbarModule,
MatTableModule,
MatTabsModule,
MatChipsModule,
MatIconModule,
MatButtonModule,
MatInputModule,
MatFormFieldModule,
MatTooltipModule,
UploadFormComponent,
QueryFormComponent
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit, OnDestroy {
title = 'Obsidian - Test Artifact Data Lake';
apiInfo: ApiInfo | null = null;
artifacts: Artifact[] = [];
filteredArtifacts: Artifact[] = [];
displayedColumns: string[] = ['id', 'filename', 'file_type', 'file_size', 'test_name', 'test_result', 'actions'];
selectedTabIndex = 0;
searchTerm: string = '';
autoRefreshEnabled: boolean = true;
private autoRefreshInterval: any;
constructor(
private apiService: ApiService,
private artifactService: ArtifactService
) {}
ngOnInit(): void {
this.loadApiInfo();
this.loadArtifacts();
this.startAutoRefresh();
}
ngOnDestroy(): void {
this.stopAutoRefresh();
}
loadApiInfo(): void {
this.apiService.getApiInfo().subscribe({
next: (info) => {
this.apiInfo = info;
},
error: (error) => {
console.error('Error loading API info:', error);
}
});
}
loadArtifacts(): void {
this.artifactService.getArtifacts().subscribe({
next: (artifacts) => {
console.log('Loaded artifacts:', artifacts.length);
this.artifacts = artifacts;
this.filterTable();
},
error: (error) => {
console.error('Error loading artifacts:', error);
}
});
}
filterTable(): void {
if (!this.searchTerm) {
this.filteredArtifacts = this.artifacts;
return;
}
const term = this.searchTerm.toLowerCase();
this.filteredArtifacts = this.artifacts.filter(artifact => {
return (
artifact.filename?.toLowerCase().includes(term) ||
artifact.test_name?.toLowerCase().includes(term) ||
artifact.test_suite?.toLowerCase().includes(term) ||
artifact.file_type?.toLowerCase().includes(term)
);
});
}
clearSearch(): void {
this.searchTerm = '';
this.filterTable();
}
toggleAutoRefresh(): void {
this.autoRefreshEnabled = !this.autoRefreshEnabled;
if (this.autoRefreshEnabled) {
this.startAutoRefresh();
} else {
this.stopAutoRefresh();
}
}
startAutoRefresh(): void {
this.stopAutoRefresh();
if (this.autoRefreshEnabled) {
this.autoRefreshInterval = setInterval(() => {
if (this.selectedTabIndex === 0) {
this.loadArtifacts();
}
}, 5000);
}
}
stopAutoRefresh(): void {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
}
generateSeedData(): void {
const count = prompt('How many artifacts to generate? (1-100)', '10');
if (!count) return;
const num = parseInt(count);
if (isNaN(num) || num < 1 || num > 100) {
alert('Please enter a number between 1 and 100');
return;
}
this.artifactService.generateSeedData(num).subscribe({
next: (result: any) => {
alert(result.message || `Successfully generated ${num} artifacts`);
this.loadArtifacts();
},
error: (error) => {
alert('Error generating seed data: ' + error.message);
}
});
}
downloadArtifact(artifact: Artifact): void {
this.artifactService.downloadArtifact(artifact.id).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = artifact.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
error: (error) => {
alert('Error downloading artifact: ' + error.message);
}
});
}
deleteArtifact(artifact: Artifact): void {
if (!confirm(`Are you sure you want to delete "${artifact.filename}"? This cannot be undone.`)) {
return;
}
this.artifactService.deleteArtifact(artifact.id).subscribe({
next: () => {
alert('Artifact deleted successfully');
this.loadArtifacts();
},
error: (error) => {
alert('Error deleting artifact: ' + error.message);
}
});
}
onUploadSuccess(): void {
this.loadArtifacts();
this.selectedTabIndex = 0;
}
onQueryResults(artifacts: Artifact[]): void {
this.artifacts = artifacts;
this.filterTable();
this.selectedTabIndex = 0;
}
onFiltersChange(filters: any): void {
console.log('Filters changed:', filters);
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
}

View File

@@ -1,134 +0,0 @@
/* App Layout */
.app-layout {
display: flex;
min-height: 100vh;
background: #0f172a;
color: #e2e8f0;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
margin-right: 280px;
transition: margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.main-content.sidebar-collapsed {
margin-right: 80px;
}
/* Top Header */
.top-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
background: #1e293b;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.top-header h1 {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: 600;
color: #f1f5f9;
margin: 0;
}
.logo {
font-family: 'Courier New', monospace;
color: #3b82f6;
font-weight: 700;
letter-spacing: -1px;
}
.header-info {
display: flex;
gap: 12px;
align-items: center;
}
.badge {
padding: 6px 14px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid rgba(59, 130, 246, 0.2);
}
/* Content Area */
.content-area {
flex: 1;
padding: 24px;
overflow-y: auto;
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.main-content {
margin-right: 240px;
}
}
@media (max-width: 768px) {
.main-content {
margin-right: 0;
}
.top-header {
padding: 12px 16px;
}
.top-header h1 {
font-size: 18px;
}
.content-area {
padding: 16px;
}
.header-info {
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
}
/* Smooth Scrolling */
.content-area::-webkit-scrollbar {
width: 8px;
}
.content-area::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.3);
border-radius: 4px;
}
.content-area::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
/* Global Material Icons Sizing */
.material-icons.md-16 {
font-size: 16px;
}
.material-icons.md-18 {
font-size: 18px;
}
.material-icons.md-20 {
font-size: 20px;
}
.material-icons.md-24 {
font-size: 24px;
}

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,15 +1,3 @@
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';
import { SettingsComponent } from './components/settings/settings';
import { ProfileComponent } from './components/profile/profile';
export const routes: Routes = [
{ path: '', redirectTo: '/artifacts', pathMatch: 'full' },
{ path: 'artifacts', component: ArtifactsListComponent },
{ path: 'upload', component: UploadFormComponent },
{ path: 'query', component: QueryFormComponent },
{ path: 'settings', component: SettingsComponent },
{ path: 'profile', component: ProfileComponent }
];
export const routes: Routes = [];

View File

@@ -1,52 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { ArtifactService } from './services/artifact';
import { NavSidebarComponent } from './components/nav-sidebar/nav-sidebar';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, NavSidebarComponent],
template: `
<div class="app-layout">
<app-nav-sidebar (sidebarToggled)="onSidebarToggle($event)"></app-nav-sidebar>
<main class="main-content" [class.sidebar-collapsed]="isSidebarCollapsed">
<header class="top-header">
<h1><span class="logo">[W13]</span> Warehouse13</h1>
<div class="header-info">
<span class="badge">{{ deploymentMode }}</span>
<span class="badge">{{ storageBackend }}</span>
</div>
</header>
<div class="content-area">
<router-outlet></router-outlet>
</div>
</main>
</div>
`,
styleUrls: ['./app.css']
})
export class AppComponent implements OnInit {
deploymentMode: string = '';
storageBackend: string = '';
isSidebarCollapsed: boolean = false;
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)
});
}
onSidebarToggle(isCollapsed: boolean) {
this.isSidebarCollapsed = isCollapsed;
}
}

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

@@ -1,276 +0,0 @@
.sidebar {
position: fixed;
right: 0;
top: 0;
height: 100vh;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 280px;
display: flex;
flex-direction: column;
z-index: 1000;
overflow: hidden;
}
.sidebar.collapsed {
width: 80px;
}
/* Toggle Button */
.toggle-btn {
position: absolute;
left: -18px;
top: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
background: #3b82f6;
border: 2px solid #0f172a;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.5);
transition: all 0.2s ease;
z-index: 1001;
overflow: visible;
}
.toggle-btn:hover {
background: #2563eb;
transform: scale(1.1);
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.6);
}
.toggle-btn .material-icons {
font-size: 22px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* User Section */
.user-section {
padding: 24px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 40px;
}
.sidebar.collapsed .user-section {
padding: 24px 8px;
}
.user-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s ease;
}
.sidebar.collapsed .user-avatar {
width: 48px;
height: 48px;
}
.avatar-text {
font-size: 20px;
font-weight: 600;
color: white;
letter-spacing: 1px;
}
.user-info {
text-align: center;
width: 100%;
overflow: hidden;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 12px;
color: #94a3b8;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 11px;
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
padding: 4px 12px;
border-radius: 12px;
display: inline-block;
}
/* Navigation Items */
.nav-items {
flex: 1;
padding: 16px 8px;
overflow-y: auto;
overflow-x: hidden;
}
.nav-items::-webkit-scrollbar {
width: 6px;
}
.nav-items::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.nav-items::-webkit-scrollbar-track {
background: transparent;
}
.nav-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
color: #cbd5e1;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
position: relative;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 12px 8px;
}
.nav-item:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 24px;
background: #3b82f6;
border-radius: 0 4px 4px 0;
}
.nav-icon {
font-size: 24px;
flex-shrink: 0;
}
.nav-label {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar.collapsed .nav-label {
display: none;
}
/* Footer */
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.app-info {
text-align: center;
}
.app-name {
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 4px;
}
.app-version {
font-size: 11px;
color: #64748b;
}
/* Responsive Design */
@media (max-width: 1024px) {
.sidebar {
width: 240px;
}
.sidebar.collapsed {
width: 70px;
}
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
transform: translateX(100%);
}
.sidebar:not(.collapsed) {
transform: translateX(0);
}
.sidebar.collapsed {
width: 60px;
transform: translateX(0);
}
.toggle-btn {
left: auto;
right: 16px;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.nav-label,
.user-info {
animation: slideIn 0.3s ease;
}

View File

@@ -1,40 +0,0 @@
<aside class="sidebar" [class.collapsed]="isCollapsed">
<!-- Toggle Button -->
<button class="toggle-btn" (click)="toggleSidebar()" aria-label="Toggle sidebar">
<span class="material-icons">{{ isCollapsed ? 'chevron_right' : 'chevron_left' }}</span>
</button>
<!-- User Profile Section -->
<div class="user-section">
<div class="user-avatar">
<span class="avatar-text">{{ user.avatar }}</span>
</div>
<div class="user-info" *ngIf="!isCollapsed">
<div class="user-name">{{ user.name }}</div>
<div class="user-email">{{ user.email }}</div>
<div class="user-role">{{ user.role }}</div>
</div>
</div>
<!-- Navigation Items -->
<nav class="nav-items">
<a
*ngFor="let item of navItems"
[routerLink]="item.route"
routerLinkActive="active"
class="nav-item"
[attr.aria-label]="item.label"
>
<span class="material-icons nav-icon">{{ item.icon }}</span>
<span class="nav-label" *ngIf="!isCollapsed">{{ item.label }}</span>
</a>
</nav>
<!-- App Info Footer -->
<div class="sidebar-footer" *ngIf="!isCollapsed">
<div class="app-info">
<div class="app-name">Warehouse13</div>
<div class="app-version">v1.0.0</div>
</div>
</div>
</aside>

View File

@@ -1,43 +0,0 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
interface NavItem {
route: string;
label: string;
icon: string;
}
@Component({
selector: 'app-nav-sidebar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive],
templateUrl: './nav-sidebar.html',
styleUrls: ['./nav-sidebar.css']
})
export class NavSidebarComponent {
isCollapsed = false;
@Output() sidebarToggled = new EventEmitter<boolean>();
// Hardcoded user data for now (will be replaced with OAuth)
user = {
name: 'John Doe',
email: 'john.doe@warehouse13.com',
avatar: 'JD',
role: 'Administrator'
};
navItems: NavItem[] = [
{ route: '/artifacts', label: 'Artifacts', icon: 'inventory_2' },
{ route: '/upload', label: 'Upload', icon: 'cloud_upload' },
{ route: '/query', label: 'Query', icon: 'search' },
{ route: '/profile', label: 'Profile', icon: 'person' },
{ route: '/settings', label: 'Settings', icon: 'settings' }
];
toggleSidebar() {
this.isCollapsed = !this.isCollapsed;
this.sidebarToggled.emit(this.isCollapsed);
}
}

View File

@@ -1,395 +0,0 @@
.profile-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 12px;
font-size: 28px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px 0;
}
.page-header h1 .material-icons {
font-size: 32px;
color: #3b82f6;
}
.subtitle {
color: #94a3b8;
margin: 0;
}
.profile-content {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Profile Card */
.profile-card {
background: #1e293b;
border-radius: 12px;
padding: 32px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.profile-header {
display: flex;
align-items: flex-start;
gap: 24px;
margin-bottom: 32px;
}
.profile-avatar-large {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 24px rgba(59, 130, 246, 0.3);
}
.avatar-text {
font-size: 36px;
font-weight: 600;
color: white;
letter-spacing: 2px;
}
.profile-info {
flex: 1;
}
.profile-info h2 {
font-size: 24px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px 0;
}
.email {
color: #94a3b8;
margin: 0 0 12px 0;
font-size: 14px;
}
.profile-meta {
display: flex;
align-items: center;
gap: 16px;
}
.badge-role {
padding: 6px 14px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.department {
display: flex;
align-items: center;
gap: 6px;
color: #cbd5e1;
font-size: 13px;
}
.department .material-icons {
font-size: 18px;
}
.btn-edit {
padding: 10px 20px;
border-radius: 8px;
border: 1px solid #3b82f6;
background: transparent;
color: #3b82f6;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.btn-edit:hover {
background: rgba(59, 130, 246, 0.1);
}
.profile-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.detail-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.detail-item > .material-icons {
font-size: 20px;
color: #64748b;
margin-top: 2px;
}
.detail-item label {
display: block;
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.detail-item span {
display: block;
font-size: 14px;
color: #f1f5f9;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.stat-card {
background: #1e293b;
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 16px;
transition: all 0.2s ease;
}
.stat-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
}
.stat-icon {
font-size: 36px;
color: #3b82f6;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #94a3b8;
}
/* Activity Section */
.activity-section,
.security-section {
background: #1e293b;
border-radius: 12px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.activity-section h2,
.security-section h2 {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.activity-section h2 .material-icons,
.security-section h2 .material-icons {
font-size: 24px;
color: #3b82f6;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.activity-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: rgba(15, 23, 42, 0.5);
border-radius: 8px;
transition: all 0.2s ease;
}
.activity-item:hover {
background: rgba(59, 130, 246, 0.05);
}
.activity-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(59, 130, 246, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.activity-icon .material-icons {
font-size: 20px;
color: #3b82f6;
}
.activity-details {
flex: 1;
}
.activity-action {
font-size: 14px;
color: #f1f5f9;
margin-bottom: 4px;
}
.activity-file {
font-size: 12px;
color: #94a3b8;
}
.activity-time {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
/* Security Section */
.security-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.security-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: rgba(15, 23, 42, 0.5);
border-radius: 8px;
}
.security-info {
display: flex;
align-items: flex-start;
gap: 16px;
flex: 1;
}
.security-info > .material-icons {
font-size: 24px;
color: #64748b;
margin-top: 2px;
}
.security-info h3 {
font-size: 15px;
font-weight: 500;
color: #f1f5f9;
margin: 0 0 4px 0;
}
.security-info p {
font-size: 13px;
color: #94a3b8;
margin: 0;
}
.btn-secondary {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #334155;
background: transparent;
color: #cbd5e1;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
}
.btn-secondary:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
color: #3b82f6;
}
/* Responsive */
@media (max-width: 768px) {
.profile-container {
padding: 16px;
}
.profile-header {
flex-direction: column;
text-align: center;
}
.profile-avatar-large {
margin: 0 auto;
}
.profile-info {
text-align: center;
}
.profile-meta {
justify-content: center;
}
.btn-edit {
width: 100%;
justify-content: center;
}
.stats-grid {
grid-template-columns: 1fr;
}
.security-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.btn-secondary {
width: 100%;
}
}

View File

@@ -1,128 +0,0 @@
<div class="profile-container">
<header class="page-header">
<h1>
<span class="material-icons">person</span>
Profile
</h1>
<p class="subtitle">Manage your account information</p>
</header>
<div class="profile-content">
<!-- User Info Card -->
<section class="profile-card">
<div class="profile-header">
<div class="profile-avatar-large">
<span class="avatar-text">{{ user.avatar }}</span>
</div>
<div class="profile-info">
<h2>{{ user.name }}</h2>
<p class="email">{{ user.email }}</p>
<div class="profile-meta">
<span class="badge badge-role">{{ user.role }}</span>
<span class="department">
<span class="material-icons">business</span>
{{ user.department }}
</span>
</div>
</div>
<button class="btn-edit">
<span class="material-icons">edit</span>
Edit Profile
</button>
</div>
<div class="profile-details">
<div class="detail-item">
<span class="material-icons">event</span>
<div>
<label>Joined</label>
<span>{{ user.joinDate }}</span>
</div>
</div>
<div class="detail-item">
<span class="material-icons">schedule</span>
<div>
<label>Last Login</label>
<span>{{ user.lastLogin }}</span>
</div>
</div>
</div>
</section>
<!-- Stats Grid -->
<section class="stats-grid">
<div class="stat-card" *ngFor="let stat of stats">
<span class="material-icons stat-icon">{{ stat.icon }}</span>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</section>
<!-- Recent Activity -->
<section class="activity-section">
<h2>
<span class="material-icons">history</span>
Recent Activity
</h2>
<div class="activity-list">
<div class="activity-item" *ngFor="let activity of recentActivity">
<div class="activity-icon">
<span class="material-icons">{{
activity.action.includes('Uploaded') ? 'cloud_upload' :
activity.action.includes('query') ? 'search' :
activity.action.includes('Downloaded') ? 'cloud_download' :
'settings'
}}</span>
</div>
<div class="activity-details">
<div class="activity-action">{{ activity.action }}</div>
<div class="activity-file">{{ activity.file }}</div>
</div>
<div class="activity-time">{{ activity.time }}</div>
</div>
</div>
</section>
<!-- Security Section -->
<section class="security-section">
<h2>
<span class="material-icons">security</span>
Security
</h2>
<div class="security-content">
<div class="security-item">
<div class="security-info">
<span class="material-icons">lock</span>
<div>
<h3>Password</h3>
<p>Last changed 3 months ago</p>
</div>
</div>
<button class="btn-secondary">Change Password</button>
</div>
<div class="security-item">
<div class="security-info">
<span class="material-icons">verified_user</span>
<div>
<h3>Two-Factor Authentication</h3>
<p>Add an extra layer of security</p>
</div>
</div>
<button class="btn-secondary">Enable 2FA</button>
</div>
<div class="security-item">
<div class="security-info">
<span class="material-icons">devices</span>
<div>
<h3>Active Sessions</h3>
<p>Manage your active login sessions</p>
</div>
</div>
<button class="btn-secondary">View Sessions</button>
</div>
</div>
</section>
</div>
</div>

View File

@@ -1,36 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './profile.html',
styleUrls: ['./profile.css']
})
export class ProfileComponent {
// Hardcoded user data (will be replaced with OAuth integration)
user = {
name: 'John Doe',
email: 'john.doe@warehouse13.com',
avatar: 'JD',
role: 'Administrator',
department: 'Engineering',
joinDate: 'January 15, 2024',
lastLogin: 'October 16, 2025, 2:30 PM'
};
stats = [
{ label: 'Artifacts Uploaded', value: '1,234', icon: 'cloud_upload' },
{ label: 'Queries Run', value: '567', icon: 'search' },
{ label: 'Storage Used', value: '45.2 GB', icon: 'storage' },
{ label: 'Active Since', value: '9 months', icon: 'schedule' }
];
recentActivity = [
{ action: 'Uploaded artifact', file: 'test_results.csv', time: '2 hours ago' },
{ action: 'Ran query', file: 'integration tests', time: '5 hours ago' },
{ action: 'Downloaded artifact', file: 'performance_metrics.json', time: '1 day ago' },
{ action: 'Updated settings', file: 'notification preferences', time: '2 days ago' }
];
}

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

@@ -1,227 +0,0 @@
.settings-container {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 12px;
font-size: 28px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px 0;
}
.page-header h1 .material-icons {
font-size: 32px;
color: #3b82f6;
}
.subtitle {
color: #94a3b8;
margin: 0;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-section {
background: #1e293b;
border-radius: 12px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.settings-section h2 {
font-size: 18px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.setting-item:last-child {
border-bottom: none;
}
.setting-info {
display: flex;
align-items: flex-start;
gap: 16px;
flex: 1;
}
.setting-info > .material-icons {
font-size: 24px;
color: #64748b;
margin-top: 2px;
}
.setting-info h3 {
font-size: 15px;
font-weight: 500;
color: #f1f5f9;
margin: 0 0 4px 0;
}
.setting-info p {
font-size: 13px;
color: #94a3b8;
margin: 0;
}
.setting-control {
display: flex;
align-items: center;
gap: 12px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #334155;
transition: 0.3s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #3b82f6;
}
input:checked + .slider:before {
transform: translateX(22px);
}
/* Theme Buttons */
.theme-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid #334155;
background: transparent;
color: #cbd5e1;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
}
.theme-btn:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
color: #3b82f6;
}
.theme-btn.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Buttons */
.btn-secondary {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #334155;
background: transparent;
color: #cbd5e1;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
text-decoration: none;
display: inline-block;
}
.btn-secondary:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
color: #3b82f6;
}
/* Badge */
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
/* Code */
.endpoint {
padding: 6px 12px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #3b82f6;
}
/* Responsive */
@media (max-width: 768px) {
.settings-container {
padding: 16px;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.setting-control {
width: 100%;
justify-content: flex-end;
}
}

View File

@@ -1,167 +0,0 @@
<div class="settings-container">
<header class="page-header">
<h1>
<span class="material-icons">settings</span>
Settings
</h1>
<p class="subtitle">Manage your Warehouse13 preferences</p>
</header>
<div class="settings-content">
<!-- General Settings -->
<section class="settings-section">
<h2>General</h2>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">palette</span>
<div>
<h3>Theme</h3>
<p>Choose your preferred color scheme</p>
</div>
</div>
<div class="setting-control">
<button
[class.active]="settings.theme === 'dark'"
(click)="changeTheme('dark')"
class="theme-btn">
Dark
</button>
<button
[class.active]="settings.theme === 'light'"
(click)="changeTheme('light')"
class="theme-btn">
Light
</button>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">notifications</span>
<div>
<h3>Notifications</h3>
<p>Enable desktop notifications for updates</p>
</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input
type="checkbox"
[checked]="settings.notifications"
(change)="toggleNotifications()">
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">refresh</span>
<div>
<h3>Auto Refresh</h3>
<p>Automatically refresh artifact list</p>
</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input
type="checkbox"
[checked]="settings.autoRefresh"
(change)="toggleAutoRefresh()">
<span class="slider"></span>
</label>
</div>
</div>
</section>
<!-- Storage Settings -->
<section class="settings-section">
<h2>Storage</h2>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">storage</span>
<div>
<h3>Default Storage Backend</h3>
<p>Currently using: MinIO</p>
</div>
</div>
<div class="setting-control">
<span class="badge badge-success">Connected</span>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">cloud</span>
<div>
<h3>Upload Limit</h3>
<p>Maximum file size: 500MB</p>
</div>
</div>
</div>
</section>
<!-- API Settings -->
<section class="settings-section">
<h2>API</h2>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">link</span>
<div>
<h3>API Endpoint</h3>
<p>Backend service endpoint</p>
</div>
</div>
<div class="setting-control">
<code class="endpoint">/api/v1</code>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">key</span>
<div>
<h3>API Keys</h3>
<p>Manage API authentication tokens</p>
</div>
</div>
<div class="setting-control">
<button class="btn-secondary">Manage Keys</button>
</div>
</div>
</section>
<!-- About Section -->
<section class="settings-section">
<h2>About</h2>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">info</span>
<div>
<h3>Version</h3>
<p>Warehouse13 v1.0.0</p>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="material-icons">description</span>
<div>
<h3>Documentation</h3>
<p>View API documentation and guides</p>
</div>
</div>
<div class="setting-control">
<a href="/docs" target="_blank" class="btn-secondary">
View Docs
</a>
</div>
</div>
</section>
</div>
</div>

View File

@@ -1,30 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule],
templateUrl: './settings.html',
styleUrls: ['./settings.css']
})
export class SettingsComponent {
settings = {
theme: 'dark',
notifications: true,
autoRefresh: false,
refreshInterval: 30
};
toggleNotifications() {
this.settings.notifications = !this.settings.notifications;
}
toggleAutoRefresh() {
this.settings.autoRefresh = !this.settings.autoRefresh;
}
changeTheme(theme: string) {
this.settings.theme = theme;
}
}

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_size: number;
storage_path: string;
content_type: string | null;
test_name: string | null;
test_suite: string | null;
test_config: any;
test_result: string | null;
sim_source_id: string | null;
custom_metadata: any;
description: string | null;
tags: string[] | null;
test_name?: string;
test_suite?: string;
test_result?: 'pass' | 'fail' | 'skip' | 'error';
test_config?: any;
custom_metadata?: any;
description?: string;
tags: string[];
version?: string;
created_at: string;
updated_at: string;
version: string | null;
parent_id: number | null;
event_id?: string;
binaries?: string[];
}
export interface ArtifactQuery {
@@ -25,7 +24,6 @@ export interface ArtifactQuery {
test_name?: string;
test_suite?: string;
test_result?: string;
sim_source_id?: string;
tags?: string[];
start_date?: string;
end_date?: string;
@@ -36,5 +34,18 @@ export interface ArtifactQuery {
export interface ApiInfo {
deployment_mode: 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="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
]
},
"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.
*/}}
{{- define "warehouse13.name" -}}
{{- define "datalake.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "warehouse13.fullname" -}}
{{- define "datalake.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
@@ -24,16 +24,16 @@ Create a default fully qualified app name.
{{/*
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 "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "warehouse13.labels" -}}
helm.sh/chart: {{ include "warehouse13.chart" . }}
{{ include "warehouse13.selectorLabels" . }}
{{- define "datalake.labels" -}}
helm.sh/chart: {{ include "datalake.chart" . }}
{{ include "datalake.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
@@ -43,29 +43,18 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
{{/*
Selector labels
*/}}
{{- define "warehouse13.selectorLabels" -}}
app.kubernetes.io/name: {{ include "warehouse13.name" . }}
{{- define "datalake.selectorLabels" -}}
app.kubernetes.io/name: {{ include "datalake.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "warehouse13.serviceAccountName" -}}
{{- define "datalake.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "warehouse13.fullname" .) .Values.serviceAccount.name }}
{{- default (include "datalake.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- 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
kind: Ingress
metadata:
name: warehouse13-ingress
name: {{ include "datalake.fullname" . }}
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
{{- include "datalake.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
@@ -33,9 +33,9 @@ spec:
pathType: {{ .pathType }}
backend:
service:
name: {{ printf "warehouse13-%s" .backend }}
name: {{ include "datalake.fullname" $ }}
port:
number: {{ $.Values.app.service.port }}
number: {{ $.Values.service.port }}
{{- 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
kind: ServiceAccount
metadata:
name: {{ include "warehouse13.serviceAccountName" . }}
name: {{ include "datalake.serviceAccountName" . }}
labels:
{{- include "warehouse13.labels" . | nindent 4 }}
{{- include "datalake.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- 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/

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