Compare commits
20 Commits
main
...
d7390a3a80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7390a3a80 | ||
|
|
4a84c08a2b | ||
|
|
d3a14a2b31 | ||
|
|
ed511b1e3c | ||
|
|
e81b426d9f | ||
|
|
b5605d1276 | ||
| 872464dec2 | |||
| 629e3e4125 | |||
|
|
eccdb2f072 | ||
| 0e1a342917 | |||
| 6f7e9a517e | |||
| 5fdefecbd0 | |||
|
|
2ee203b012 | ||
| d5c8bd188b | |||
|
|
ca91fdfa15 | ||
|
|
9733eec2af | ||
|
|
9303f3481b | ||
|
|
fbb1dfa67b | ||
| ed5773893e | |||
|
|
82e8cea256 |
45
.claude/settings.local.json
Normal file
45
.claude/settings.local.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(ng add:*)",
|
||||
"Bash(npm run start:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(./quickstart.sh:*)",
|
||||
"Bash(docker-compose:*)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(quickstart.bat --help)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(Get-Process -Name \"node\" -ErrorAction SilentlyContinue)",
|
||||
"Bash(Stop-Process -Force)",
|
||||
"Bash(python -m uvicorn:*)",
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(docker:*)",
|
||||
"Bash(npm start)",
|
||||
"Bash(python:*)",
|
||||
"Bash(ng serve:*)",
|
||||
"Bash(if exist .angular rmdir /s /q .angular)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(git ls-tree:*)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Read(//c/Users/Pratik/Desktop/code/**)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(Start-Sleep -Seconds 10)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -27,3 +27,8 @@ MINIO_SECURE=false
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
MAX_UPLOAD_SIZE=524288000
|
||||
|
||||
# NPM Configuration (for frontend build)
|
||||
# Leave blank or set to https://registry.npmjs.org/ for default npm registry
|
||||
# Set to your custom npm proxy/registry URL if needed (e.g., http://your-nexus-server:8081/repository/npm-proxy/)
|
||||
NPM_REGISTRY=
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -86,3 +86,11 @@ helm/charts/
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
.claude/settings.local.json
|
||||
|
||||
# Node/NPM
|
||||
frontend/node_modules/
|
||||
frontend/.angular/
|
||||
frontend/dist/
|
||||
# Keep .npmrc but ignore sensitive auth info
|
||||
# The active .npmrc will be generated from .npmrc.public or .npmrc.artifactory
|
||||
|
||||
465
DEPLOYMENT.md
465
DEPLOYMENT.md
@@ -1,465 +0,0 @@
|
||||
# Deployment Guide
|
||||
|
||||
This guide covers deploying the Test Artifact Data Lake in various environments.
|
||||
|
||||
## Table of Contents
|
||||
- [Local Development](#local-development)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Kubernetes/Helm](#kuberneteshelm)
|
||||
- [AWS Deployment](#aws-deployment)
|
||||
- [Self-Hosted Deployment](#self-hosted-deployment)
|
||||
- [GitLab CI/CD](#gitlab-cicd)
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15+
|
||||
- MinIO or AWS S3 access
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create virtual environment:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Set up PostgreSQL:**
|
||||
```bash
|
||||
createdb datalake
|
||||
```
|
||||
|
||||
4. **Configure environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
5. **Run the application:**
|
||||
```bash
|
||||
python -m uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Start all services:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Check logs:**
|
||||
```bash
|
||||
docker-compose logs -f api
|
||||
```
|
||||
|
||||
3. **Stop services:**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Services Included
|
||||
- PostgreSQL (port 5432)
|
||||
- MinIO (port 9000, console 9001)
|
||||
- API (port 8000)
|
||||
|
||||
### Customization
|
||||
|
||||
Edit `docker-compose.yml` to:
|
||||
- Change port mappings
|
||||
- Adjust resource limits
|
||||
- Add environment variables
|
||||
- Configure volumes
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes/Helm
|
||||
|
||||
### Prerequisites
|
||||
- Kubernetes cluster (1.24+)
|
||||
- Helm 3.x
|
||||
- kubectl configured
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Add dependencies (if using PostgreSQL/MinIO from Bitnami):**
|
||||
```bash
|
||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
||||
helm repo update
|
||||
```
|
||||
|
||||
2. **Install with default values:**
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--create-namespace
|
||||
```
|
||||
|
||||
3. **Custom installation:**
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--create-namespace \
|
||||
--set image.repository=your-registry/datalake \
|
||||
--set image.tag=1.0.0 \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake.yourdomain.com
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
**Image:**
|
||||
```bash
|
||||
--set image.repository=your-registry/datalake
|
||||
--set image.tag=1.0.0
|
||||
--set image.pullPolicy=Always
|
||||
```
|
||||
|
||||
**Resources:**
|
||||
```bash
|
||||
--set resources.requests.cpu=1000m
|
||||
--set resources.requests.memory=1Gi
|
||||
--set resources.limits.cpu=2000m
|
||||
--set resources.limits.memory=2Gi
|
||||
```
|
||||
|
||||
**Autoscaling:**
|
||||
```bash
|
||||
--set autoscaling.enabled=true
|
||||
--set autoscaling.minReplicas=3
|
||||
--set autoscaling.maxReplicas=10
|
||||
--set autoscaling.targetCPUUtilizationPercentage=80
|
||||
```
|
||||
|
||||
**Ingress:**
|
||||
```bash
|
||||
--set ingress.enabled=true
|
||||
--set ingress.className=nginx
|
||||
--set ingress.hosts[0].host=datalake.example.com
|
||||
--set ingress.hosts[0].paths[0].path=/
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix
|
||||
```
|
||||
|
||||
### Upgrade
|
||||
|
||||
```bash
|
||||
helm upgrade datalake ./helm \
|
||||
--namespace datalake \
|
||||
--set image.tag=1.1.0
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
helm uninstall datalake --namespace datalake
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AWS Deployment
|
||||
|
||||
### Using AWS S3 Storage
|
||||
|
||||
1. **Create S3 bucket:**
|
||||
```bash
|
||||
aws s3 mb s3://your-test-artifacts-bucket
|
||||
```
|
||||
|
||||
2. **Create IAM user with S3 access:**
|
||||
```bash
|
||||
aws iam create-user --user-name datalake-service
|
||||
aws iam attach-user-policy --user-name datalake-service \
|
||||
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
|
||||
```
|
||||
|
||||
3. **Generate access keys:**
|
||||
```bash
|
||||
aws iam create-access-key --user-name datalake-service
|
||||
```
|
||||
|
||||
4. **Deploy with Helm:**
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--create-namespace \
|
||||
--set config.storageBackend=s3 \
|
||||
--set aws.enabled=true \
|
||||
--set aws.accessKeyId=YOUR_ACCESS_KEY \
|
||||
--set aws.secretAccessKey=YOUR_SECRET_KEY \
|
||||
--set aws.region=us-east-1 \
|
||||
--set aws.bucketName=your-test-artifacts-bucket \
|
||||
--set minio.enabled=false
|
||||
```
|
||||
|
||||
### Using EKS
|
||||
|
||||
1. **Create EKS cluster:**
|
||||
```bash
|
||||
eksctl create cluster \
|
||||
--name datalake-cluster \
|
||||
--region us-east-1 \
|
||||
--nodegroup-name standard-workers \
|
||||
--node-type t3.medium \
|
||||
--nodes 3
|
||||
```
|
||||
|
||||
2. **Configure kubectl:**
|
||||
```bash
|
||||
aws eks update-kubeconfig --name datalake-cluster --region us-east-1
|
||||
```
|
||||
|
||||
3. **Deploy application:**
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--create-namespace \
|
||||
--set config.storageBackend=s3
|
||||
```
|
||||
|
||||
### Using RDS for PostgreSQL
|
||||
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--create-namespace \
|
||||
--set postgresql.enabled=false \
|
||||
--set config.databaseUrl="postgresql://user:pass@your-rds-endpoint:5432/datalake"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted Deployment
|
||||
|
||||
### Using MinIO
|
||||
|
||||
1. **Deploy MinIO:**
|
||||
```bash
|
||||
helm install minio bitnami/minio \
|
||||
--namespace datalake \
|
||||
--create-namespace \
|
||||
--set auth.rootUser=admin \
|
||||
--set auth.rootPassword=adminpassword \
|
||||
--set persistence.size=100Gi
|
||||
```
|
||||
|
||||
2. **Deploy application:**
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--set config.storageBackend=minio \
|
||||
--set minio.enabled=false \
|
||||
--set minio.endpoint=minio:9000 \
|
||||
--set minio.accessKey=admin \
|
||||
--set minio.secretKey=adminpassword
|
||||
```
|
||||
|
||||
### On-Premise Kubernetes
|
||||
|
||||
1. **Prepare persistent volumes:**
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: datalake-postgres-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 20Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
hostPath:
|
||||
path: /data/postgres
|
||||
```
|
||||
|
||||
2. **Deploy with local storage:**
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--namespace datalake \
|
||||
--create-namespace \
|
||||
--set postgresql.persistence.storageClass=local-storage \
|
||||
--set minio.persistence.storageClass=local-storage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GitLab CI/CD
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Configure GitLab variables:**
|
||||
|
||||
Go to Settings → CI/CD → Variables and add:
|
||||
|
||||
| Variable | Description | Protected | Masked |
|
||||
|----------|-------------|-----------|---------|
|
||||
| `CI_REGISTRY_USER` | Docker registry username | No | No |
|
||||
| `CI_REGISTRY_PASSWORD` | Docker registry password | No | Yes |
|
||||
| `KUBE_CONFIG_DEV` | Base64 kubeconfig for dev | No | Yes |
|
||||
| `KUBE_CONFIG_STAGING` | Base64 kubeconfig for staging | Yes | Yes |
|
||||
| `KUBE_CONFIG_PROD` | Base64 kubeconfig for prod | Yes | Yes |
|
||||
|
||||
2. **Encode kubeconfig:**
|
||||
```bash
|
||||
cat ~/.kube/config | base64 -w 0
|
||||
```
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
1. **Test**: Runs on all branches and MRs
|
||||
2. **Build**: Builds Docker image on main/develop/tags
|
||||
3. **Deploy**: Manual deployment to dev/staging/prod
|
||||
|
||||
### Deployment Flow
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
git push origin develop
|
||||
# Manually trigger deploy:dev job in GitLab
|
||||
```
|
||||
|
||||
**Staging:**
|
||||
```bash
|
||||
git push origin main
|
||||
# Manually trigger deploy:staging job in GitLab
|
||||
```
|
||||
|
||||
**Production:**
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
# Manually trigger deploy:prod job in GitLab
|
||||
```
|
||||
|
||||
### Customizing Pipeline
|
||||
|
||||
Edit `.gitlab-ci.yml` to:
|
||||
- Add more test stages
|
||||
- Change deployment namespaces
|
||||
- Adjust Helm values per environment
|
||||
- Add security scanning
|
||||
- Configure rollback procedures
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Kubernetes
|
||||
kubectl get pods -n datalake
|
||||
kubectl logs -f -n datalake deployment/datalake
|
||||
|
||||
# Direct
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
Add Prometheus monitoring:
|
||||
```bash
|
||||
helm install datalake ./helm \
|
||||
--set metrics.enabled=true \
|
||||
--set serviceMonitor.enabled=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
kubectl exec -n datalake deployment/datalake-postgresql -- \
|
||||
pg_dump -U user datalake > backup.sql
|
||||
|
||||
# Restore
|
||||
kubectl exec -i -n datalake deployment/datalake-postgresql -- \
|
||||
psql -U user datalake < backup.sql
|
||||
```
|
||||
|
||||
### Storage Backup
|
||||
|
||||
**S3:**
|
||||
```bash
|
||||
aws s3 sync s3://your-bucket s3://backup-bucket
|
||||
```
|
||||
|
||||
**MinIO:**
|
||||
```bash
|
||||
mc mirror minio/test-artifacts backup/test-artifacts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pod Not Starting
|
||||
```bash
|
||||
kubectl describe pod -n datalake <pod-name>
|
||||
kubectl logs -n datalake <pod-name>
|
||||
```
|
||||
|
||||
### Database Connection Issues
|
||||
```bash
|
||||
kubectl exec -it -n datalake deployment/datalake -- \
|
||||
psql $DATABASE_URL
|
||||
```
|
||||
|
||||
### Storage Issues
|
||||
```bash
|
||||
# Check MinIO
|
||||
kubectl port-forward -n datalake svc/minio 9000:9000
|
||||
# Access http://localhost:9000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Use secrets management:**
|
||||
- Kubernetes Secrets
|
||||
- AWS Secrets Manager
|
||||
- HashiCorp Vault
|
||||
|
||||
2. **Enable TLS:**
|
||||
- Configure ingress with TLS certificates
|
||||
- Use cert-manager for automatic certificates
|
||||
|
||||
3. **Network policies:**
|
||||
- Restrict pod-to-pod communication
|
||||
- Limit external access
|
||||
|
||||
4. **RBAC:**
|
||||
- Configure Kubernetes RBAC
|
||||
- Limit service account permissions
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Database
|
||||
- Increase connection pool size
|
||||
- Add database indexes
|
||||
- Configure autovacuum
|
||||
|
||||
### API
|
||||
- Increase replica count
|
||||
- Configure horizontal pod autoscaling
|
||||
- Adjust resource requests/limits
|
||||
|
||||
### Storage
|
||||
- Use CDN for frequently accessed files
|
||||
- Configure S3 Transfer Acceleration
|
||||
- Optimize MinIO deployment
|
||||
44
Dockerfile
44
Dockerfile
@@ -1,15 +1,49 @@
|
||||
# Multi-stage build: First stage builds Angular frontend
|
||||
FROM node:18-alpine as frontend-builder
|
||||
|
||||
# Build argument to select npm registry (public or artifactory)
|
||||
ARG NPM_REGISTRY=public
|
||||
ARG ARTIFACTORY_AUTH_TOKEN=""
|
||||
|
||||
# Install dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Copy package files and registry configs
|
||||
COPY frontend/package*.json ./
|
||||
COPY frontend/.npmrc.${NPM_REGISTRY} ./.npmrc
|
||||
|
||||
# If using artifactory and auth token is provided, configure it
|
||||
RUN if [ "$NPM_REGISTRY" = "artifactory" ] && [ -n "$ARTIFACTORY_AUTH_TOKEN" ]; then \
|
||||
echo "Configuring Artifactory authentication..."; \
|
||||
fi
|
||||
|
||||
# Clean install dependencies
|
||||
RUN npm ci --force
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/src ./src
|
||||
COPY frontend/public ./public
|
||||
COPY frontend/angular.json ./
|
||||
COPY frontend/tsconfig*.json ./
|
||||
|
||||
# Build the Angular app for production
|
||||
RUN npm run build
|
||||
|
||||
# Second stage: Python backend with Angular static files
|
||||
FROM python:3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for Alpine
|
||||
# Alpine uses apk instead of apt-get and is lighter/faster
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
postgresql-dev \
|
||||
postgresql-client \
|
||||
linux-headers
|
||||
linux-headers \
|
||||
curl
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
@@ -20,7 +54,9 @@ COPY app/ ./app/
|
||||
COPY utils/ ./utils/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
COPY static/ ./static/
|
||||
|
||||
# Copy Angular build from frontend-builder stage
|
||||
COPY --from=frontend-builder /frontend/dist/frontend/browser ./static
|
||||
|
||||
# Create non-root user (Alpine uses adduser instead of useradd)
|
||||
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
|
||||
@@ -31,7 +67,7 @@ EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:8000/health')"
|
||||
CMD curl -f http://localhost:8000/health
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
38
Dockerfile.frontend
Normal file
38
Dockerfile.frontend
Normal 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 from the browser subdirectory
|
||||
COPY --from=frontend-builder /frontend/dist/frontend/browser /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;"]
|
||||
15
Dockerfile.frontend.prebuilt
Normal file
15
Dockerfile.frontend.prebuilt
Normal file
@@ -0,0 +1,15 @@
|
||||
# Dockerfile for pre-built Angular frontend (air-gapped/restricted environments)
|
||||
# Build the Angular app locally first: cd frontend && npm run build:prod
|
||||
# Then use this Dockerfile to package the pre-built files
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy pre-built Angular app to nginx
|
||||
COPY frontend/dist/frontend/browser /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
14
Dockerfile.frontend.simple
Normal file
14
Dockerfile.frontend.simple
Normal 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;"]
|
||||
46
README.md
46
README.md
@@ -40,17 +40,12 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./quickstart.sh
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
.\quickstart.ps1
|
||||
```
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```batch
|
||||
quickstart.bat
|
||||
.\scripts\quickstart.ps1
|
||||
```
|
||||
|
||||
### Manual Setup with Docker Compose
|
||||
@@ -274,6 +269,23 @@ Store compiled binaries, test data files, or any binary artifacts with full meta
|
||||
|
||||
## Development
|
||||
|
||||
### NPM Registry Configuration
|
||||
|
||||
The frontend supports working with multiple npm registries (public npm vs corporate Artifactory). See [frontend/README-REGISTRY.md](frontend/README-REGISTRY.md) for detailed instructions.
|
||||
|
||||
**Quick switch:**
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Use public npm (default)
|
||||
npm run registry:public
|
||||
npm ci --force
|
||||
|
||||
# Use Artifactory
|
||||
npm run registry:artifactory
|
||||
npm ci --force
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
@@ -308,6 +320,26 @@ alembic upgrade head
|
||||
- Verify `MINIO_ENDPOINT` is correct
|
||||
- Check MinIO credentials
|
||||
|
||||
## Documentation
|
||||
|
||||
Detailed documentation is available in the `docs/` folder:
|
||||
|
||||
- **[Quick Start Guide](docs/QUICKSTART.md)** - Get started in minutes
|
||||
- **[API Documentation](docs/API.md)** - Complete API reference
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** - System design and architecture
|
||||
- **[Features](docs/FEATURES.md)** - Detailed feature descriptions
|
||||
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Production deployment instructions
|
||||
- **[Frontend Setup](docs/FRONTEND_SETUP.md)** - Angular frontend setup
|
||||
- **[Frontend Usage](docs/FRONTEND_USAGE.md)** - Using the web UI
|
||||
|
||||
## Scripts
|
||||
|
||||
Helper scripts are available in the `scripts/` folder:
|
||||
|
||||
- **`quickstart.sh` / `quickstart.ps1`** - Quick start with Docker Compose
|
||||
- **`quickstart-build.sh`** - Quick start with image rebuild
|
||||
- **`dev-start.sh` / `dev-start.ps1`** - Start development environment
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
|
||||
117
app/api/tags.py
Normal file
117
app/api/tags.py
Normal 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
|
||||
@@ -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():
|
||||
|
||||
54
app/main.py
54
app/main.py
@@ -4,10 +4,12 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from app.api.artifacts import router as artifacts_router
|
||||
from app.api.seed import router as seed_router
|
||||
from app.api.tags import router as tags_router
|
||||
from app.database import init_db
|
||||
from app.config import settings
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -19,8 +21,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Obsidian",
|
||||
description="Enterprise Test Artifact Storage - API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
|
||||
title="Test Artifact Data Lake",
|
||||
description="API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
@@ -38,11 +40,10 @@ app.add_middleware(
|
||||
# Include routers
|
||||
app.include_router(artifacts_router)
|
||||
app.include_router(seed_router)
|
||||
app.include_router(tags_router)
|
||||
|
||||
# Mount static files
|
||||
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
# Static files configuration - will be set up after routes
|
||||
static_dir = Path("/app/static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -59,7 +60,7 @@ async def startup_event():
|
||||
async def api_root():
|
||||
"""API root endpoint"""
|
||||
return {
|
||||
"message": "Obsidian - Enterprise Test Artifact Storage",
|
||||
"message": "Test Artifact Data Lake API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"deployment_mode": settings.deployment_mode,
|
||||
@@ -68,17 +69,17 @@ async def api_root():
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def ui_root():
|
||||
"""Serve the UI"""
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
async def serve_angular_app():
|
||||
"""Serve Angular app index.html"""
|
||||
static_dir = Path("/app/static")
|
||||
if static_dir.exists():
|
||||
return FileResponse(static_dir / "index.html")
|
||||
else:
|
||||
# Fallback if static files not found
|
||||
return {
|
||||
"message": "Obsidian - Enterprise Test Artifact Storage",
|
||||
"message": "Test Artifact Data Lake API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"ui": "UI not found. Serving API only.",
|
||||
"deployment_mode": settings.deployment_mode,
|
||||
"storage_backend": settings.storage_backend
|
||||
}
|
||||
@@ -90,6 +91,31 @@ async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
# Catch-all route for Angular client-side routing
|
||||
# This must be last to not interfere with API routes
|
||||
@app.get("/{full_path:path}")
|
||||
async def catch_all(full_path: str):
|
||||
"""Serve Angular app for all non-API routes (SPA routing)"""
|
||||
if static_dir.exists():
|
||||
# Check if the requested path is a file in the static directory
|
||||
file_path = static_dir / full_path
|
||||
if file_path.is_file() and file_path.exists():
|
||||
# Determine media type based on file extension
|
||||
media_type = None
|
||||
if file_path.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
elif file_path.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
elif file_path.suffix in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico"]:
|
||||
media_type = f"image/{file_path.suffix[1:]}"
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
# Otherwise, serve index.html for client-side routing
|
||||
index_path = static_dir / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(index_path, media_type="text/html")
|
||||
return {"error": "Static files not found"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
|
||||
@@ -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
21
app/models/tag.py
Normal 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}')>"
|
||||
@@ -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
28
app/schemas/tag.py
Normal 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
156
dev-start.ps1
Normal 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
89
dev-start.sh
Normal 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
|
||||
77
docker-compose.production.yml
Normal file
77
docker-compose.production.yml
Normal 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:
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -52,10 +52,11 @@ services:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
113
docs/DEPLOYMENT.md
Normal file
113
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Deployment Options
|
||||
|
||||
This project supports two deployment strategies for the Angular frontend, depending on your environment's network access.
|
||||
|
||||
## Option 1: Standard Build (Internet Access Required)
|
||||
|
||||
Use the standard `Dockerfile.frontend` which builds the Angular app inside Docker.
|
||||
|
||||
**Requirements:**
|
||||
- Internet access to npm registry
|
||||
- Docker build environment
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./quickstart.sh
|
||||
# or
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
This uses `Dockerfile.frontend` which:
|
||||
1. Installs npm dependencies in Docker
|
||||
2. Builds Angular app in Docker
|
||||
3. Serves with nginx
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Pre-built Deployment (Air-Gapped/Restricted Environments)
|
||||
|
||||
Use `Dockerfile.frontend.prebuilt` for environments with restricted npm access or when esbuild platform binaries cannot be downloaded.
|
||||
|
||||
**Requirements:**
|
||||
- Node.js 24+ installed locally
|
||||
- npm installed locally
|
||||
- No internet required during Docker build
|
||||
|
||||
**Usage:**
|
||||
|
||||
### Step 1: Build Angular app locally
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # Only needed once or when dependencies change
|
||||
npm run build:prod
|
||||
cd ..
|
||||
```
|
||||
|
||||
### Step 2: Update docker-compose.yml
|
||||
Edit `docker-compose.yml` and change the frontend dockerfile:
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend.prebuilt # <-- Change this line
|
||||
ports:
|
||||
- "4200:80"
|
||||
depends_on:
|
||||
- api
|
||||
```
|
||||
|
||||
### Step 3: Build and deploy
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
This uses `Dockerfile.frontend.prebuilt` which:
|
||||
1. Copies pre-built Angular files from `frontend/dist/`
|
||||
2. Serves with nginx
|
||||
3. No npm/node required in Docker
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### esbuild Platform Binary Issues
|
||||
|
||||
If you see errors like:
|
||||
```
|
||||
Could not resolve "@esbuild/darwin-arm64"
|
||||
```
|
||||
|
||||
**Solution 1:** Use Option 2 (Pre-built) above
|
||||
|
||||
**Solution 2:** Add platform binaries to package.json (already included):
|
||||
```json
|
||||
"optionalDependencies": {
|
||||
"@esbuild/darwin-arm64": "^0.25.4",
|
||||
"@esbuild/darwin-x64": "^0.25.4",
|
||||
"@esbuild/linux-arm64": "^0.25.4",
|
||||
"@esbuild/linux-x64": "^0.25.4"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution 3:** Use custom npm registry with cached esbuild binaries
|
||||
|
||||
### Custom NPM Registry
|
||||
|
||||
For both options, you can use a custom npm registry:
|
||||
|
||||
```bash
|
||||
# Set in .env file
|
||||
NPM_REGISTRY=http://your-npm-proxy:8081/repository/npm-proxy/
|
||||
|
||||
# Or inline
|
||||
NPM_REGISTRY=http://your-proxy ./quickstart.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
- **Development/Cloud**: Use Option 1 (standard)
|
||||
- **Air-gapped/Enterprise**: Use Option 2 (pre-built)
|
||||
- **CI/CD**: Use Option 2 for faster, more reliable builds
|
||||
114
docs/FRONTEND_USAGE.md
Normal file
114
docs/FRONTEND_USAGE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Frontend Usage Guide
|
||||
|
||||
The Test Artifact Data Lake now features a modern Angular frontend with Material Design components. This guide explains how to run the application in different modes.
|
||||
|
||||
## Quick Start Options
|
||||
|
||||
### 1. Development Mode (Recommended for Development)
|
||||
**Hot reload enabled, fastest for development**
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./dev-start.sh
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```batch
|
||||
dev-start.bat
|
||||
```
|
||||
|
||||
- Backend services: `http://localhost:8000`
|
||||
- Frontend: `http://localhost:4200` (with hot reload)
|
||||
- API Docs: `http://localhost:8000/docs`
|
||||
- MinIO Console: `http://localhost:9001`
|
||||
|
||||
### 2. Production Mode (Complete Docker Stack)
|
||||
**Pre-built frontend served via Nginx**
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./quickstart-build.sh
|
||||
```
|
||||
|
||||
**Windows:** (Manual steps)
|
||||
```batch
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
- Complete application: `http://localhost:80`
|
||||
- API (proxied): `http://localhost:80/api/`
|
||||
- API Docs: `http://localhost:80/docs`
|
||||
- MinIO Console: `http://localhost:9001`
|
||||
|
||||
### 3. Backend Only Mode
|
||||
**For API-only usage or custom frontend setup**
|
||||
|
||||
**Any platform:**
|
||||
```bash
|
||||
./quickstart.sh # Linux/macOS
|
||||
quickstart.bat # Windows
|
||||
quickstart.ps1 # PowerShell
|
||||
```
|
||||
|
||||
- Backend API: `http://localhost:8000`
|
||||
- API Docs: `http://localhost:8000/docs`
|
||||
- MinIO Console: `http://localhost:9001`
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
- **Frontend**: Angular 19 with Angular Material Design
|
||||
- **Backend**: FastAPI with PostgreSQL and MinIO
|
||||
- **Development**: Frontend dev server + Backend containers
|
||||
- **Production**: Nginx serving Angular + Backend containers
|
||||
|
||||
### Ports
|
||||
- `80` - Production frontend (Nginx)
|
||||
- `4200` - Development frontend (Angular dev server)
|
||||
- `8000` - Backend API (FastAPI)
|
||||
- `5432` - PostgreSQL database
|
||||
- `9000` - MinIO storage
|
||||
- `9001` - MinIO console
|
||||
|
||||
### Development Workflow
|
||||
1. Use `dev-start.sh` or `dev-start.bat` for daily development
|
||||
2. Frontend changes automatically reload at `http://localhost:4200`
|
||||
3. Backend API available at `http://localhost:8000`
|
||||
4. Use browser dev tools for debugging
|
||||
|
||||
### Production Deployment
|
||||
1. Build frontend: `npm run build` in `frontend/` directory
|
||||
2. Use `docker-compose.production.yml` for complete stack
|
||||
3. Nginx proxies API requests to backend
|
||||
4. Static assets served efficiently by Nginx
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Frontend Build Issues
|
||||
If you encounter esbuild platform errors:
|
||||
1. Delete `frontend/node_modules`
|
||||
2. Run `npm install` in `frontend/` directory
|
||||
3. Try the development mode first: `./dev-start.sh`
|
||||
|
||||
### Port Conflicts
|
||||
- Development: Change Angular port in `angular.json`
|
||||
- Production: Modify `docker-compose.production.yml` ports
|
||||
|
||||
### Docker Issues
|
||||
- Ensure Docker Desktop is running
|
||||
- Try `docker-compose down` and restart
|
||||
- Check logs: `docker-compose logs -f api`
|
||||
|
||||
## Features
|
||||
- Modern Material Design interface
|
||||
- Responsive design for mobile/tablet
|
||||
- File upload with drag-and-drop
|
||||
- Advanced search and filtering
|
||||
- Tag management system
|
||||
- Real-time notifications
|
||||
- Data visualization
|
||||
- Export capabilities
|
||||
195
docs/QUICKSTART.md
Normal file
195
docs/QUICKSTART.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Quick Start Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Test Artifact Data Lake platform provides several ways to run the application depending on your use case:
|
||||
|
||||
- **Development**: Backend services in Docker + Frontend dev server with hot reload
|
||||
- **Production**: Complete stack in Docker containers
|
||||
- **Testing**: Various rebuild and cleanup options
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker Desktop** (Windows/macOS) or **Docker** + **Docker Compose** (Linux)
|
||||
- **Node.js 18+** (for development mode)
|
||||
- **npm** (for development mode)
|
||||
|
||||
## Quick Start Options
|
||||
|
||||
### 1. Development Mode (Recommended for Development)
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./quickstart.sh # Backend services only
|
||||
./dev-start.sh # Backend + Frontend dev server
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
.\quickstart.ps1 # Backend services only
|
||||
.\quickstart.ps1 -FullStack # Complete stack
|
||||
.\quickstart.ps1 -Rebuild # Rebuild containers
|
||||
.\dev-start.ps1 # Backend + Frontend dev server
|
||||
```
|
||||
|
||||
**URLs:**
|
||||
- Frontend: http://localhost:4200 (with hot reload)
|
||||
- API: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- MinIO Console: http://localhost:9001
|
||||
|
||||
### 2. Production Mode (Complete Stack)
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./quickstart.sh --full-stack
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
.\quickstart.ps1 -FullStack
|
||||
```
|
||||
|
||||
**URLs:**
|
||||
- Frontend: http://localhost:80 (production build)
|
||||
- API: http://localhost:8000
|
||||
- MinIO Console: http://localhost:9001
|
||||
|
||||
### 3. Force Rebuild (When Code Changes)
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./quickstart.sh --rebuild # Rebuild backend only
|
||||
./quickstart.sh --rebuild --full-stack # Rebuild complete stack
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
.\quickstart.ps1 -Rebuild # Rebuild backend only
|
||||
.\quickstart.ps1 -Rebuild -FullStack # Rebuild complete stack
|
||||
```
|
||||
|
||||
## Detailed Usage
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Start backend services:**
|
||||
```bash
|
||||
./quickstart.sh
|
||||
```
|
||||
|
||||
2. **Start frontend in development mode:**
|
||||
```bash
|
||||
./dev-start.sh
|
||||
```
|
||||
Or manually:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
3. **Make changes to your code** - Frontend will auto-reload
|
||||
|
||||
4. **When backend code changes:**
|
||||
```bash
|
||||
./quickstart.sh --rebuild
|
||||
```
|
||||
|
||||
### Production Testing
|
||||
|
||||
1. **Build and run complete stack:**
|
||||
```bash
|
||||
./quickstart.sh --full-stack
|
||||
```
|
||||
|
||||
2. **Test at http://localhost:80**
|
||||
|
||||
3. **When code changes:**
|
||||
```bash
|
||||
./quickstart.sh --rebuild --full-stack
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### quickstart.sh / quickstart.ps1
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| (none) | Start backend services only (default) |
|
||||
| `--full-stack` | Start complete stack including frontend |
|
||||
| `--rebuild` | Force rebuild of containers |
|
||||
| `--help` | Show help message |
|
||||
|
||||
### dev-start.sh / dev-start.ps1
|
||||
|
||||
Starts backend services + frontend development server with hot reload.
|
||||
|
||||
## Stopping Services
|
||||
|
||||
**Backend only:**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
**Complete stack:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.production.yml down
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
**Backend services:**
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
**Complete stack:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
**Specific service:**
|
||||
```bash
|
||||
docker-compose logs -f api
|
||||
docker-compose logs -f postgres
|
||||
docker-compose logs -f minio
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and modify as needed:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
The quickstart scripts will automatically create this file if it doesn't exist.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Issues
|
||||
- **Force rebuild:** Use `--rebuild` flag
|
||||
- **Clean everything:** `docker-compose down --volumes --rmi all`
|
||||
- **Check Docker:** Ensure Docker Desktop is running
|
||||
|
||||
### Frontend Issues
|
||||
- **Dependencies:** Run `npm install` in `frontend/` directory
|
||||
- **Port conflicts:** Check if port 4200 is available
|
||||
- **Node version:** Ensure Node.js 18+ is installed
|
||||
|
||||
### Backend Issues
|
||||
- **API not responding:** Wait longer for services to start (can take 30+ seconds)
|
||||
- **Database issues:** Check `docker-compose logs postgres`
|
||||
- **Storage issues:** Check `docker-compose logs minio`
|
||||
|
||||
## Development vs Production
|
||||
|
||||
| Feature | Development | Production |
|
||||
|---------|-------------|------------|
|
||||
| Frontend | Hot reload dev server | Built Angular app |
|
||||
| Port | 4200 | 80 |
|
||||
| Build time | Fast startup | Slower (builds Angular) |
|
||||
| Use case | Development, testing | Demo, staging |
|
||||
|
||||
Choose the mode that best fits your workflow!
|
||||
0
docs/todos.md
Normal file
0
docs/todos.md
Normal file
17
frontend/.editorconfig
Normal file
17
frontend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
frontend/.gitignore
vendored
Normal file
42
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
11
frontend/.npmrc.artifactory
Normal file
11
frontend/.npmrc.artifactory
Normal file
@@ -0,0 +1,11 @@
|
||||
# Replace YOUR_ARTIFACTORY_URL with your actual Artifactory URL
|
||||
registry=https://YOUR_ARTIFACTORY_URL/artifactory/api/npm/npm-virtual/
|
||||
|
||||
# If authentication is required, uncomment and configure:
|
||||
# //YOUR_ARTIFACTORY_URL/artifactory/api/npm/npm-virtual/:_auth=${ARTIFACTORY_AUTH_TOKEN}
|
||||
# //YOUR_ARTIFACTORY_URL/artifactory/api/npm/npm-virtual/:always-auth=true
|
||||
|
||||
# Alternative: username/password (less secure, not recommended)
|
||||
# //YOUR_ARTIFACTORY_URL/artifactory/api/npm/npm-virtual/:username=${ARTIFACTORY_USERNAME}
|
||||
# //YOUR_ARTIFACTORY_URL/artifactory/api/npm/npm-virtual/:_password=${ARTIFACTORY_PASSWORD}
|
||||
# //YOUR_ARTIFACTORY_URL/artifactory/api/npm/npm-virtual/:email=your-email@company.com
|
||||
1
frontend/.npmrc.public
Normal file
1
frontend/.npmrc.public
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
192
frontend/README-REGISTRY.md
Normal file
192
frontend/README-REGISTRY.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# NPM Registry Configuration
|
||||
|
||||
This project supports working with two different npm registries:
|
||||
1. **Public registry** - registry.npmjs.org (default)
|
||||
2. **Artifactory** - Your corporate Artifactory npm registry
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Switching Registries Locally
|
||||
|
||||
**On Linux/Mac:**
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Use public npm registry (default)
|
||||
./switch-registry.sh public
|
||||
npm ci --force
|
||||
|
||||
# Use Artifactory registry
|
||||
./switch-registry.sh artifactory
|
||||
# Set auth token if required
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_token_here"
|
||||
npm ci --force
|
||||
```
|
||||
|
||||
**On Windows:**
|
||||
```powershell
|
||||
cd frontend
|
||||
|
||||
# Use public npm registry (default)
|
||||
.\switch-registry.ps1 public
|
||||
npm ci --force
|
||||
|
||||
# Use Artifactory registry
|
||||
.\switch-registry.ps1 artifactory
|
||||
# Set auth token if required
|
||||
$env:ARTIFACTORY_AUTH_TOKEN = "your_token_here"
|
||||
npm ci --force
|
||||
```
|
||||
|
||||
### Building with Docker
|
||||
|
||||
**Using public npm registry (default):**
|
||||
```bash
|
||||
docker compose build app
|
||||
```
|
||||
|
||||
**Using Artifactory registry:**
|
||||
```bash
|
||||
# Without authentication
|
||||
docker compose build app --build-arg NPM_REGISTRY=artifactory
|
||||
|
||||
# With authentication
|
||||
docker compose build app \
|
||||
--build-arg NPM_REGISTRY=artifactory \
|
||||
--build-arg ARTIFACTORY_AUTH_TOKEN="your_token_here"
|
||||
```
|
||||
|
||||
**On Windows PowerShell:**
|
||||
```powershell
|
||||
# With authentication
|
||||
docker compose build app `
|
||||
--build-arg NPM_REGISTRY=artifactory `
|
||||
--build-arg ARTIFACTORY_AUTH_TOKEN="your_token_here"
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- **`.npmrc.public`** - Configuration for public npm registry
|
||||
- **`.npmrc.artifactory`** - Configuration for Artifactory registry (edit this with your Artifactory URL)
|
||||
- **`.npmrc`** - Active configuration (generated by switch-registry scripts)
|
||||
|
||||
## Setup Artifactory Configuration
|
||||
|
||||
1. Edit `frontend/.npmrc.artifactory` and replace `YOUR_ARTIFACTORY_URL` with your actual Artifactory URL:
|
||||
```
|
||||
registry=https://artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/
|
||||
```
|
||||
|
||||
2. If authentication is required, uncomment the auth lines and use one of these methods:
|
||||
|
||||
**Method 1: Auth Token (Recommended)**
|
||||
```
|
||||
//artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:_auth=${ARTIFACTORY_AUTH_TOKEN}
|
||||
//artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:always-auth=true
|
||||
```
|
||||
|
||||
Then set the environment variable:
|
||||
```bash
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_base64_encoded_token"
|
||||
```
|
||||
|
||||
**Method 2: Username/Password**
|
||||
```
|
||||
//artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:username=${ARTIFACTORY_USERNAME}
|
||||
//artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:_password=${ARTIFACTORY_PASSWORD}
|
||||
//artifactory.yourcompany.com/artifactory/api/npm/npm-virtual/:email=your-email@company.com
|
||||
```
|
||||
|
||||
## Handling package-lock.json
|
||||
|
||||
The `package-lock.json` file will be different depending on which registry you use. Here are strategies to manage this:
|
||||
|
||||
### Strategy 1: Separate Lockfiles (Recommended)
|
||||
Keep two lockfiles and switch between them:
|
||||
|
||||
```bash
|
||||
# After switching to public and installing
|
||||
npm ci --force
|
||||
cp package-lock.json package-lock.public.json
|
||||
|
||||
# After switching to artifactory and installing
|
||||
npm ci --force
|
||||
cp package-lock.json package-lock.artifactory.json
|
||||
|
||||
# When switching registries in the future
|
||||
cp package-lock.public.json package-lock.json # or
|
||||
cp package-lock.artifactory.json package-lock.json
|
||||
```
|
||||
|
||||
### Strategy 2: Regenerate Lockfile
|
||||
Always regenerate the lockfile after switching:
|
||||
|
||||
```bash
|
||||
./switch-registry.sh artifactory
|
||||
rm package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### Strategy 3: Git Ignore Lockfile (Not Recommended for Production)
|
||||
If you're frequently switching and don't need deterministic builds:
|
||||
|
||||
Add to `.gitignore`:
|
||||
```
|
||||
frontend/package-lock.json
|
||||
```
|
||||
|
||||
**Warning:** This reduces build reproducibility.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "npm ci requires package-lock.json"
|
||||
**Solution:** Delete `package-lock.json` and run `npm install` to generate a new one for your current registry.
|
||||
|
||||
### Issue: "404 Not Found - GET https://registry.npmjs.org/..."
|
||||
**Solution:** Your .npmrc is pointing to Artifactory but packages don't exist there.
|
||||
```bash
|
||||
./switch-registry.sh public
|
||||
npm ci --force
|
||||
```
|
||||
|
||||
### Issue: "401 Unauthorized"
|
||||
**Solution:** Check your authentication configuration in `.npmrc.artifactory` and ensure environment variables are set correctly.
|
||||
|
||||
### Issue: "ENOENT: no such file or directory, open '.npmrc.public'"
|
||||
**Solution:** You're missing the registry config files. Make sure both `.npmrc.public` and `.npmrc.artifactory` exist in the frontend directory.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
For CI/CD pipelines, use environment variables to select the registry:
|
||||
|
||||
**GitHub Actions Example:**
|
||||
```yaml
|
||||
- name: Build with Artifactory
|
||||
env:
|
||||
NPM_REGISTRY: artifactory
|
||||
ARTIFACTORY_AUTH_TOKEN: ${{ secrets.ARTIFACTORY_TOKEN }}
|
||||
run: |
|
||||
docker compose build app \
|
||||
--build-arg NPM_REGISTRY=artifactory \
|
||||
--build-arg ARTIFACTORY_AUTH_TOKEN="${ARTIFACTORY_AUTH_TOKEN}"
|
||||
```
|
||||
|
||||
**GitLab CI Example:**
|
||||
```yaml
|
||||
build:
|
||||
script:
|
||||
- docker compose build app
|
||||
--build-arg NPM_REGISTRY=artifactory
|
||||
--build-arg ARTIFACTORY_AUTH_TOKEN="${ARTIFACTORY_AUTH_TOKEN}"
|
||||
variables:
|
||||
NPM_REGISTRY: artifactory
|
||||
ARTIFACTORY_AUTH_TOKEN: ${CI_ARTIFACTORY_TOKEN}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Never commit credentials** - Use environment variables for tokens/passwords
|
||||
2. **Document your Artifactory URL** - Update `.npmrc.artifactory` with your team's URL
|
||||
3. **Keep both config files** - Commit `.npmrc.public` and `.npmrc.artifactory` to git
|
||||
4. **Use the scripts** - Always use `switch-registry.sh/ps1` instead of manually editing `.npmrc`
|
||||
5. **Clean installs** - Use `npm ci --force` after switching to ensure a clean dependency tree
|
||||
151
frontend/README.md
Normal file
151
frontend/README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Test Artifact Data Lake - Angular Frontend
|
||||
|
||||
This is the Angular 19 frontend for the Test Artifact Data Lake application. It replaces the static HTML/JS implementation with a modern, component-based architecture.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Multi-component Architecture**: Built with reusable Angular components
|
||||
✅ **Tab Navigation**: Clean tab-based interface for Artifacts, Upload, and Query
|
||||
✅ **Event ID Support**: Group multiple artifacts under the same event ID
|
||||
✅ **Expandable Binaries Display**: Show first 4 binaries, expandable for more
|
||||
✅ **Advanced Tag Management**: Create tags on-the-spot with database persistence
|
||||
✅ **Scoped Tags**: Organize tags by scope (project, environment, priority, etc.)
|
||||
✅ **Comprehensive Filtering**: Filter artifacts by all table criteria
|
||||
✅ **Real-time Search**: As-you-type filtering in query form
|
||||
✅ **Responsive Design**: Mobile-friendly interface
|
||||
|
||||
## Components
|
||||
|
||||
### Core Components
|
||||
- **TabNavigationComponent**: Manages tab switching between Artifacts, Upload, and Query
|
||||
- **ArtifactsTableComponent**: Displays artifacts with expandable binaries/tags and Event ID support
|
||||
- **UploadFormComponent**: File upload with Event ID and binaries support
|
||||
- **QueryFormComponent**: Advanced search with real-time filtering
|
||||
- **TagManagerComponent**: On-the-spot tag creation with scoped tags
|
||||
|
||||
### Services
|
||||
- **ArtifactService**: Handles all artifact-related API calls
|
||||
- **ApiService**: Manages general API information
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18 or later)
|
||||
- Angular CLI 19
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
npm start
|
||||
# or
|
||||
ng serve
|
||||
```
|
||||
The app will be available at `http://localhost:4200`
|
||||
|
||||
### Build for Production
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
ng build
|
||||
```
|
||||
Built files will be in `dist/frontend/`
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend expects the backend API to be available at:
|
||||
- Development: Same origin as the frontend
|
||||
- Production: Configurable via environment files
|
||||
|
||||
### Required API Endpoints
|
||||
- `GET /api` - API information
|
||||
- `GET /api/v1/artifacts/` - List artifacts
|
||||
- `GET /api/v1/artifacts/{id}` - Get artifact details
|
||||
- `POST /api/v1/artifacts/upload` - Upload artifact
|
||||
- `DELETE /api/v1/artifacts/{id}` - Delete artifact
|
||||
- `GET /api/v1/artifacts/{id}/download` - Download artifact
|
||||
- `POST /api/v1/artifacts/query` - Query artifacts
|
||||
- `POST /api/v1/seed/generate/{count}` - Generate seed data
|
||||
- `GET /api/v1/tags` - List all tags
|
||||
- `POST /api/v1/tags` - Create tag
|
||||
- `POST /api/v1/artifacts/{id}/tags` - Add tag to artifact
|
||||
- `DELETE /api/v1/artifacts/{id}/tags/{tag_id}` - Remove tag from artifact
|
||||
|
||||
## Key Features Implementation
|
||||
|
||||
### Event ID Support
|
||||
Each artifact can be assigned an Event ID to group related artifacts together. This is displayed prominently in the table and can be used for filtering.
|
||||
|
||||
### Expandable Binaries
|
||||
When an artifact has more than 4 associated binaries, only the first 4 are shown with a "+X more" button to expand and see all binaries.
|
||||
|
||||
### Advanced Tag Management
|
||||
- Create tags on-the-spot directly in the table
|
||||
- Organize tags by scope (project, environment, priority, category, status)
|
||||
- Tags persist in the database across app restarts
|
||||
- Visual indicators show which tags are already attached
|
||||
- Quick-add existing tags from a categorized list
|
||||
|
||||
### Comprehensive Filtering
|
||||
The query form provides real-time filtering by:
|
||||
- Filename (partial match)
|
||||
- File type
|
||||
- Test name
|
||||
- Test suite
|
||||
- Test result
|
||||
- Tags (comma-separated)
|
||||
- Date range
|
||||
|
||||
Filters are applied immediately as you type, and active filters are displayed as visual chips.
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### From Static to Angular
|
||||
The original static JavaScript implementation has been converted to:
|
||||
|
||||
1. **Component-based Architecture**: Each major feature is now a reusable component
|
||||
2. **Type Safety**: Full TypeScript support with proper interfaces
|
||||
3. **Reactive Programming**: Uses RxJS observables for API calls
|
||||
4. **State Management**: Centralized state management through services
|
||||
5. **Modular Design**: Easy to extend and maintain
|
||||
|
||||
### Benefits
|
||||
- **Maintainability**: Clear separation of concerns
|
||||
- **Reusability**: Components can be reused and extended
|
||||
- **Testing**: Angular's testing framework support
|
||||
- **Performance**: Optimized change detection and lazy loading
|
||||
- **Developer Experience**: Hot reload, TypeScript, and Angular DevTools
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
The Angular frontend can be served during development using `ng serve` and will proxy API calls to the backend.
|
||||
|
||||
### Production
|
||||
Build the application and serve the static files from any web server. Ensure the backend API is accessible from the same domain or configure CORS appropriately.
|
||||
|
||||
### Integration with Existing Backend
|
||||
The Angular frontend is designed to be a drop-in replacement for the static frontend. Simply:
|
||||
|
||||
1. Build the Angular app: `npm run build`
|
||||
2. Copy contents of `dist/frontend/` to your static files directory
|
||||
3. Update your backend to serve the new `index.html`
|
||||
4. Ensure API endpoints match the expected interface
|
||||
|
||||
## Browser Support
|
||||
- Chrome (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
- Edge (latest)
|
||||
|
||||
## Future Enhancements
|
||||
- Drag and drop file upload
|
||||
- Bulk operations
|
||||
- Advanced data visualization
|
||||
- Real-time updates via WebSocket
|
||||
- Export functionality
|
||||
- User authentication integration
|
||||
339
frontend/REGISTRY-EXAMPLES.md
Normal file
339
frontend/REGISTRY-EXAMPLES.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# NPM Registry - Usage Examples
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Use Public NPM (Default)
|
||||
```bash
|
||||
# Linux/Mac
|
||||
./quickstart.sh
|
||||
|
||||
# Windows
|
||||
.\quickstart.ps1
|
||||
```
|
||||
|
||||
### Use Artifactory
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_token_here"
|
||||
./quickstart.sh -bsf
|
||||
|
||||
# Windows
|
||||
$env:ARTIFACTORY_AUTH_TOKEN = "your_token_here"
|
||||
.\quickstart.ps1 -Bsf
|
||||
```
|
||||
|
||||
### Rebuild with Artifactory
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_token_here"
|
||||
./quickstart.sh --rebuild -bsf
|
||||
|
||||
# Windows
|
||||
$env:ARTIFACTORY_AUTH_TOKEN = "your_token_here"
|
||||
.\quickstart.ps1 -Rebuild -Bsf
|
||||
```
|
||||
|
||||
## Local Development (Without Docker)
|
||||
|
||||
### Switch Registry for Local Development
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Switch to public npm
|
||||
./switch-registry.sh public
|
||||
npm ci --force
|
||||
npm start
|
||||
|
||||
# Switch to Artifactory
|
||||
./switch-registry.sh artifactory
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_token"
|
||||
npm ci --force
|
||||
npm start
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
cd frontend
|
||||
|
||||
# Switch to public npm
|
||||
.\switch-registry.ps1 public
|
||||
npm ci --force
|
||||
npm start
|
||||
|
||||
# Switch to Artifactory
|
||||
.\switch-registry.ps1 artifactory
|
||||
$env:ARTIFACTORY_AUTH_TOKEN = "your_token"
|
||||
npm ci --force
|
||||
npm start
|
||||
```
|
||||
|
||||
**Using NPM Scripts (Cross-platform):**
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Switch to public npm
|
||||
npm run registry:public
|
||||
npm ci --force
|
||||
npm start
|
||||
|
||||
# Switch to Artifactory
|
||||
npm run registry:artifactory
|
||||
npm ci --force
|
||||
npm start
|
||||
```
|
||||
|
||||
## Docker Build Examples
|
||||
|
||||
### Build Specific Service with Registry
|
||||
|
||||
**Public NPM:**
|
||||
```bash
|
||||
docker compose build app
|
||||
```
|
||||
|
||||
**Artifactory:**
|
||||
```bash
|
||||
# Without auth
|
||||
docker compose build app --build-arg NPM_REGISTRY=artifactory
|
||||
|
||||
# With auth
|
||||
docker compose build app \
|
||||
--build-arg NPM_REGISTRY=artifactory \
|
||||
--build-arg ARTIFACTORY_AUTH_TOKEN="your_token"
|
||||
```
|
||||
|
||||
**Windows PowerShell:**
|
||||
```powershell
|
||||
docker compose build app `
|
||||
--build-arg NPM_REGISTRY=artifactory `
|
||||
--build-arg ARTIFACTORY_AUTH_TOKEN="your_token"
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Corporate Network Development
|
||||
When working from a corporate network that requires Artifactory:
|
||||
|
||||
1. **First time setup:**
|
||||
```bash
|
||||
# Edit .npmrc.artifactory with your Artifactory URL
|
||||
nano frontend/.npmrc.artifactory
|
||||
|
||||
# Set auth token (get from your Artifactory admin)
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_base64_token"
|
||||
|
||||
# Start with Artifactory
|
||||
./quickstart.sh -bsf
|
||||
```
|
||||
|
||||
2. **Daily development:**
|
||||
```bash
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_token"
|
||||
./quickstart.sh -bsf
|
||||
```
|
||||
|
||||
### Home/Public Network Development
|
||||
When working from home or a network with npm access:
|
||||
|
||||
```bash
|
||||
# Just run without -bsf flag
|
||||
./quickstart.sh
|
||||
```
|
||||
|
||||
### Switching Between Environments
|
||||
|
||||
**Moving from Corporate to Home:**
|
||||
```bash
|
||||
# Stop existing containers
|
||||
docker compose down
|
||||
|
||||
# Rebuild with public npm
|
||||
./quickstart.sh --rebuild
|
||||
```
|
||||
|
||||
**Moving from Home to Corporate:**
|
||||
```bash
|
||||
# Stop existing containers
|
||||
docker compose down
|
||||
|
||||
# Rebuild with Artifactory
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_token"
|
||||
./quickstart.sh --rebuild -bsf
|
||||
```
|
||||
|
||||
## Handling Multiple package-lock.json Files
|
||||
|
||||
### Save lockfiles for both registries:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Generate public lockfile
|
||||
./switch-registry.sh public
|
||||
rm package-lock.json
|
||||
npm install
|
||||
cp package-lock.json package-lock.public.json
|
||||
|
||||
# Generate artifactory lockfile
|
||||
./switch-registry.sh artifactory
|
||||
rm package-lock.json
|
||||
npm install
|
||||
cp package-lock.json package-lock.artifactory.json
|
||||
|
||||
# Add to git
|
||||
git add package-lock.public.json package-lock.artifactory.json
|
||||
```
|
||||
|
||||
### Use the appropriate lockfile:
|
||||
|
||||
```bash
|
||||
# When using public npm
|
||||
cp package-lock.public.json package-lock.json
|
||||
npm ci
|
||||
|
||||
# When using Artifactory
|
||||
cp package-lock.artifactory.json package-lock.json
|
||||
npm ci
|
||||
```
|
||||
|
||||
## CI/CD Examples
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
**.github/workflows/build.yml**
|
||||
```yaml
|
||||
name: Build
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build-public:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Build with public npm
|
||||
run: |
|
||||
docker compose build app
|
||||
docker compose up -d
|
||||
|
||||
build-artifactory:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Build with Artifactory
|
||||
env:
|
||||
ARTIFACTORY_AUTH_TOKEN: ${{ secrets.ARTIFACTORY_TOKEN }}
|
||||
run: |
|
||||
./quickstart.sh -bsf
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
**.gitlab-ci.yml**
|
||||
```yaml
|
||||
variables:
|
||||
NPM_REGISTRY: "public"
|
||||
|
||||
build:public:
|
||||
stage: build
|
||||
script:
|
||||
- docker compose build app
|
||||
- docker compose up -d
|
||||
only:
|
||||
- main
|
||||
|
||||
build:artifactory:
|
||||
stage: build
|
||||
variables:
|
||||
NPM_REGISTRY: "artifactory"
|
||||
script:
|
||||
- export ARTIFACTORY_AUTH_TOKEN="${CI_ARTIFACTORY_TOKEN}"
|
||||
- ./quickstart.sh -bsf
|
||||
only:
|
||||
- develop
|
||||
```
|
||||
|
||||
### Jenkins Pipeline
|
||||
|
||||
**Jenkinsfile**
|
||||
```groovy
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
ARTIFACTORY_AUTH_TOKEN = credentials('artifactory-npm-token')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Build with Artifactory') {
|
||||
steps {
|
||||
sh './quickstart.sh -bsf'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build fails with "Cannot find .npmrc.public"
|
||||
|
||||
**Problem:** Registry config files are missing.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd frontend
|
||||
# Verify files exist
|
||||
ls -la .npmrc.*
|
||||
|
||||
# If missing, they should be committed to git
|
||||
git status
|
||||
```
|
||||
|
||||
### "ENOENT: no such file or directory, open '/frontend/dist/frontend/browser'"
|
||||
|
||||
**Problem:** Frontend build failed due to registry issues.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check build logs
|
||||
docker compose logs app | grep npm
|
||||
|
||||
# Try rebuilding with verbose logging
|
||||
docker compose build app --no-cache --progress=plain
|
||||
```
|
||||
|
||||
### npm ci fails with 404 errors
|
||||
|
||||
**Problem:** Wrong registry is configured.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd frontend
|
||||
cat .npmrc # Check which registry is active
|
||||
|
||||
# If using wrong one, switch:
|
||||
npm run registry:public # or registry:artifactory
|
||||
npm ci --force
|
||||
```
|
||||
|
||||
### Authentication fails with Artifactory
|
||||
|
||||
**Problem:** Token is invalid or not set.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check token is set
|
||||
echo $ARTIFACTORY_AUTH_TOKEN # Linux/Mac
|
||||
echo $env:ARTIFACTORY_AUTH_TOKEN # Windows
|
||||
|
||||
# Get new token from Artifactory UI:
|
||||
# Artifactory -> User Profile -> Generate Token
|
||||
|
||||
# Set the token
|
||||
export ARTIFACTORY_AUTH_TOKEN="your_new_token"
|
||||
```
|
||||
111
frontend/angular.json
Normal file
111
frontend/angular.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13838
frontend/package-lock-bsf.json
Normal file
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
15273
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"registry:public": "node -e \"require('fs').copyFileSync('.npmrc.public', '.npmrc'); console.log('✓ Switched to public npm registry');\"",
|
||||
"registry:artifactory": "node -e \"require('fs').copyFileSync('.npmrc.artifactory', '.npmrc'); console.log('✓ Switched to Artifactory registry');\""
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/material": "^19.2.19",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.17",
|
||||
"@angular/cli": "^19.2.17",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
11
frontend/proxy.conf.json
Normal file
11
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"pathRewrite": {
|
||||
"^/api": "/api"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
152
frontend/src/app/app.component.html
Normal file
152
frontend/src/app/app.component.html
Normal 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>
|
||||
409
frontend/src/app/app.component.scss
Normal file
409
frontend/src/app/app.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/app.component.spec.ts
Normal file
29
frontend/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'frontend' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('frontend');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
215
frontend/src/app/app.component.ts
Normal file
215
frontend/src/app/app.component.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
13
frontend/src/app/app.config.ts
Normal file
13
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient()
|
||||
]
|
||||
};
|
||||
0
frontend/src/app/app.css
Normal file
0
frontend/src/app/app.css
Normal file
342
frontend/src/app/app.html
Normal file
342
frontend/src/app/app.html
Normal file
@@ -0,0 +1,342 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * 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 />
|
||||
3
frontend/src/app/app.routes.ts
Normal file
3
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
23
frontend/src/app/app.spec.ts
Normal file
23
frontend/src/app/app.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
53
frontend/src/app/app.ts
Normal file
53
frontend/src/app/app.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { ArtifactService } from './services/artifact';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>◆ Obsidian</h1>
|
||||
<div class="header-info">
|
||||
<span class="badge">{{ deploymentMode }}</span>
|
||||
<span class="badge">{{ storageBackend }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<a routerLink="/artifacts" routerLinkActive="active" class="tab-button">
|
||||
<span class="material-icons md-16">storage</span> Artifacts
|
||||
</a>
|
||||
<a routerLink="/upload" routerLinkActive="active" class="tab-button">
|
||||
<span class="material-icons md-16">upload</span> Upload
|
||||
</a>
|
||||
<a routerLink="/query" routerLinkActive="active" class="tab-button">
|
||||
<span class="material-icons md-16">search</span> Query
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./app.css']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
deploymentMode: string = '';
|
||||
storageBackend: string = '';
|
||||
|
||||
constructor(private artifactService: ArtifactService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.artifactService.getApiInfo().subscribe({
|
||||
next: (info) => {
|
||||
this.deploymentMode = `Mode: ${info.deployment_mode}`;
|
||||
this.storageBackend = `Storage: ${info.storage_backend}`;
|
||||
},
|
||||
error: (err) => console.error('Failed to load API info:', err)
|
||||
});
|
||||
}
|
||||
}
|
||||
190
frontend/src/app/components/artifacts-list/artifacts-list.html
Normal file
190
frontend/src/app/components/artifacts-list/artifacts-list.html
Normal file
@@ -0,0 +1,190 @@
|
||||
<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()">×</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>
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
235
frontend/src/app/components/artifacts-list/artifacts-list.ts
Normal file
235
frontend/src/app/components/artifacts-list/artifacts-list.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ArtifactService } from '../../services/artifact';
|
||||
import { Artifact } from '../../models/artifact.model';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-artifacts-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './artifacts-list.html',
|
||||
styleUrls: ['./artifacts-list.css']
|
||||
})
|
||||
export class ArtifactsListComponent implements OnInit, OnDestroy {
|
||||
artifacts: Artifact[] = [];
|
||||
filteredArtifacts: Artifact[] = [];
|
||||
selectedArtifact: Artifact | null = null;
|
||||
searchTerm: string = '';
|
||||
|
||||
// Pagination
|
||||
currentPage: number = 1;
|
||||
pageSize: number = 25;
|
||||
|
||||
// Auto-refresh
|
||||
autoRefreshEnabled: boolean = true;
|
||||
private refreshSubscription?: Subscription;
|
||||
private readonly REFRESH_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
// Sorting
|
||||
sortColumn: string | null = null;
|
||||
sortDirection: 'asc' | 'desc' = 'asc';
|
||||
|
||||
loading: boolean = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(private artifactService: ArtifactService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadArtifacts();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
loadArtifacts() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const offset = (this.currentPage - 1) * this.pageSize;
|
||||
this.artifactService.listArtifacts(this.pageSize, offset).subscribe({
|
||||
next: (artifacts) => {
|
||||
this.artifacts = artifacts;
|
||||
this.applyFilter();
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load artifacts: ' + err.message;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter() {
|
||||
if (!this.searchTerm) {
|
||||
this.filteredArtifacts = [...this.artifacts];
|
||||
} else {
|
||||
const term = this.searchTerm.toLowerCase();
|
||||
this.filteredArtifacts = this.artifacts.filter(artifact =>
|
||||
artifact.filename.toLowerCase().includes(term) ||
|
||||
(artifact.test_name && artifact.test_name.toLowerCase().includes(term)) ||
|
||||
(artifact.test_suite && artifact.test_suite.toLowerCase().includes(term)) ||
|
||||
(artifact.sim_source_id && artifact.sim_source_id.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
if (this.sortColumn) {
|
||||
this.applySorting();
|
||||
}
|
||||
}
|
||||
|
||||
onSearch() {
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchTerm = '';
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
sortTable(column: string) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.applySorting();
|
||||
}
|
||||
|
||||
applySorting() {
|
||||
if (!this.sortColumn) return;
|
||||
|
||||
this.filteredArtifacts.sort((a, b) => {
|
||||
const aVal = (a as any)[this.sortColumn!] || '';
|
||||
const bVal = (b as any)[this.sortColumn!] || '';
|
||||
|
||||
let comparison = 0;
|
||||
if (this.sortColumn === 'created_at') {
|
||||
comparison = new Date(aVal).getTime() - new Date(bVal).getTime();
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return this.sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.loadArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.currentPage++;
|
||||
this.loadArtifacts();
|
||||
}
|
||||
|
||||
toggleAutoRefresh() {
|
||||
this.autoRefreshEnabled = !this.autoRefreshEnabled;
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh();
|
||||
} else {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private startAutoRefresh() {
|
||||
if (!this.autoRefreshEnabled) return;
|
||||
|
||||
this.refreshSubscription = interval(this.REFRESH_INTERVAL)
|
||||
.pipe(switchMap(() => this.artifactService.listArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize)))
|
||||
.subscribe({
|
||||
next: (artifacts) => {
|
||||
this.artifacts = artifacts;
|
||||
this.applyFilter();
|
||||
},
|
||||
error: (err) => console.error('Auto-refresh error:', err)
|
||||
});
|
||||
}
|
||||
|
||||
private stopAutoRefresh() {
|
||||
if (this.refreshSubscription) {
|
||||
this.refreshSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
showDetail(artifact: Artifact) {
|
||||
this.selectedArtifact = artifact;
|
||||
}
|
||||
|
||||
closeDetail() {
|
||||
this.selectedArtifact = null;
|
||||
}
|
||||
|
||||
downloadArtifact(artifact: Artifact, event: Event) {
|
||||
event.stopPropagation();
|
||||
this.artifactService.downloadArtifact(artifact.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = artifact.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
},
|
||||
error: (err) => alert('Download failed: ' + err.message)
|
||||
});
|
||||
}
|
||||
|
||||
deleteArtifact(artifact: Artifact, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (!confirm(`Are you sure you want to delete ${artifact.filename}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifactService.deleteArtifact(artifact.id).subscribe({
|
||||
next: () => {
|
||||
this.loadArtifacts();
|
||||
if (this.selectedArtifact?.id === artifact.id) {
|
||||
this.closeDetail();
|
||||
}
|
||||
},
|
||||
error: (err) => alert('Delete failed: ' + err.message)
|
||||
});
|
||||
}
|
||||
|
||||
generateSeedData() {
|
||||
const count = prompt('How many artifacts to generate? (1-100)', '10');
|
||||
if (!count) return;
|
||||
|
||||
const num = parseInt(count);
|
||||
if (isNaN(num) || num < 1 || num > 100) {
|
||||
alert('Please enter a number between 1 and 100');
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifactService.generateSeedData(num).subscribe({
|
||||
next: (result) => {
|
||||
alert(result.message);
|
||||
this.loadArtifacts();
|
||||
},
|
||||
error: (err) => alert('Generation failed: ' + err.message)
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
197
frontend/src/app/components/query-form/query-form.component.html
Normal file
197
frontend/src/app/components/query-form/query-form.component.html
Normal 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>
|
||||
207
frontend/src/app/components/query-form/query-form.component.scss
Normal file
207
frontend/src/app/components/query-form/query-form.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
137
frontend/src/app/components/query-form/query-form.component.ts
Normal file
137
frontend/src/app/components/query-form/query-form.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
102
frontend/src/app/components/query-form/query-form.html
Normal file
102
frontend/src/app/components/query-form/query-form.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<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>
|
||||
23
frontend/src/app/components/query-form/query-form.spec.ts
Normal file
23
frontend/src/app/components/query-form/query-form.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
83
frontend/src/app/components/query-form/query-form.ts
Normal file
83
frontend/src/app/components/query-form/query-form.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { ArtifactService } from '../../services/artifact';
|
||||
import { Artifact, ArtifactQuery } from '../../models/artifact.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-query-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './query-form.html',
|
||||
styleUrls: ['./query-form.css']
|
||||
})
|
||||
export class QueryFormComponent {
|
||||
queryForm: FormGroup;
|
||||
@Output() resultsFound = new EventEmitter<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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
frontend/src/app/components/tag-manager/tag-manager.component.ts
Normal file
173
frontend/src/app/components/tag-manager/tag-manager.component.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
176
frontend/src/app/components/upload-form/upload-form.component.ts
Normal file
176
frontend/src/app/components/upload-form/upload-form.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
109
frontend/src/app/components/upload-form/upload-form.html
Normal file
109
frontend/src/app/components/upload-form/upload-form.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<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>
|
||||
23
frontend/src/app/components/upload-form/upload-form.spec.ts
Normal file
23
frontend/src/app/components/upload-form/upload-form.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
132
frontend/src/app/components/upload-form/upload-form.ts
Normal file
132
frontend/src/app/components/upload-form/upload-form.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ArtifactService } from '../../services/artifact';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './upload-form.html',
|
||||
styleUrls: ['./upload-form.css']
|
||||
})
|
||||
export class UploadFormComponent {
|
||||
uploadForm: FormGroup;
|
||||
selectedFile: File | null = null;
|
||||
uploading: boolean = false;
|
||||
uploadStatus: { message: string, success: boolean } | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private artifactService: ArtifactService
|
||||
) {
|
||||
this.uploadForm = this.fb.group({
|
||||
file: [null, Validators.required],
|
||||
sim_source: ['', Validators.required],
|
||||
uploaded_by: ['', Validators.required],
|
||||
sim_source_id: [''],
|
||||
tags: ['', Validators.required],
|
||||
test_result: [''],
|
||||
version: [''],
|
||||
description: [''],
|
||||
test_config: [''],
|
||||
custom_metadata: ['']
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
this.selectedFile = input.files[0];
|
||||
this.uploadForm.patchValue({ file: this.selectedFile });
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.uploadForm.valid || !this.selectedFile) {
|
||||
this.showStatus('Please fill in all required fields and select a file', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON fields
|
||||
const testConfig = this.uploadForm.value.test_config;
|
||||
const customMetadata = this.uploadForm.value.custom_metadata;
|
||||
|
||||
if (testConfig) {
|
||||
try {
|
||||
JSON.parse(testConfig);
|
||||
} catch (e) {
|
||||
this.showStatus('Invalid Test Config JSON', false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (customMetadata) {
|
||||
try {
|
||||
JSON.parse(customMetadata);
|
||||
} catch (e) {
|
||||
this.showStatus('Invalid Custom Metadata JSON', false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedFile);
|
||||
formData.append('test_suite', this.uploadForm.value.sim_source);
|
||||
formData.append('test_name', this.uploadForm.value.uploaded_by);
|
||||
|
||||
if (this.uploadForm.value.sim_source_id) {
|
||||
formData.append('sim_source_id', this.uploadForm.value.sim_source_id);
|
||||
}
|
||||
|
||||
// Parse and append tags as JSON array
|
||||
if (this.uploadForm.value.tags) {
|
||||
const tagsArray = this.uploadForm.value.tags
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter((t: string) => t);
|
||||
formData.append('tags', JSON.stringify(tagsArray));
|
||||
}
|
||||
|
||||
if (this.uploadForm.value.test_result) {
|
||||
formData.append('test_result', this.uploadForm.value.test_result);
|
||||
}
|
||||
|
||||
if (this.uploadForm.value.version) {
|
||||
formData.append('version', this.uploadForm.value.version);
|
||||
}
|
||||
|
||||
if (this.uploadForm.value.description) {
|
||||
formData.append('description', this.uploadForm.value.description);
|
||||
}
|
||||
|
||||
if (testConfig) {
|
||||
formData.append('test_config', testConfig);
|
||||
}
|
||||
|
||||
if (customMetadata) {
|
||||
formData.append('custom_metadata', customMetadata);
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.artifactService.uploadArtifact(formData).subscribe({
|
||||
next: (artifact) => {
|
||||
this.showStatus(`Successfully uploaded: ${artifact.filename}`, true);
|
||||
this.uploadForm.reset();
|
||||
this.selectedFile = null;
|
||||
this.uploading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.showStatus('Upload failed: ' + err.error?.detail || err.message, false);
|
||||
this.uploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showStatus(message: string, success: boolean) {
|
||||
this.uploadStatus = { message, success };
|
||||
setTimeout(() => {
|
||||
this.uploadStatus = null;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
51
frontend/src/app/models/artifact.interface.ts
Normal file
51
frontend/src/app/models/artifact.interface.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
filename: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
storage_path: string;
|
||||
test_name?: string;
|
||||
test_suite?: string;
|
||||
test_result?: 'pass' | 'fail' | 'skip' | 'error';
|
||||
test_config?: any;
|
||||
custom_metadata?: any;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
version?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
event_id?: string;
|
||||
binaries?: string[];
|
||||
}
|
||||
|
||||
export interface ArtifactQuery {
|
||||
filename?: string;
|
||||
file_type?: string;
|
||||
test_name?: string;
|
||||
test_suite?: string;
|
||||
test_result?: string;
|
||||
tags?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ApiInfo {
|
||||
deployment_mode: string;
|
||||
storage_backend: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
id: number;
|
||||
filename: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id?: number;
|
||||
name: string;
|
||||
scope?: string;
|
||||
color?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
40
frontend/src/app/models/artifact.model.ts
Normal file
40
frontend/src/app/models/artifact.model.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
filename: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
storage_path: string;
|
||||
content_type: string | null;
|
||||
test_name: string | null;
|
||||
test_suite: string | null;
|
||||
test_config: any;
|
||||
test_result: string | null;
|
||||
sim_source_id: string | null;
|
||||
custom_metadata: any;
|
||||
description: string | null;
|
||||
tags: string[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: string | null;
|
||||
parent_id: number | null;
|
||||
}
|
||||
|
||||
export interface ArtifactQuery {
|
||||
filename?: string;
|
||||
file_type?: string;
|
||||
test_name?: string;
|
||||
test_suite?: string;
|
||||
test_result?: string;
|
||||
sim_source_id?: string;
|
||||
tags?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ApiInfo {
|
||||
deployment_mode: string;
|
||||
storage_backend: string;
|
||||
version: string;
|
||||
}
|
||||
18
frontend/src/app/services/api.service.ts
Normal file
18
frontend/src/app/services/api.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
72
frontend/src/app/services/artifact.service.ts
Normal file
72
frontend/src/app/services/artifact.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
frontend/src/app/services/artifact.ts
Normal file
51
frontend/src/app/services/artifact.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Artifact, ArtifactQuery, ApiInfo } from '../models/artifact.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ArtifactService {
|
||||
private apiUrl = '/api/v1/artifacts';
|
||||
private baseUrl = '/api';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getApiInfo(): Observable<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}`, {});
|
||||
}
|
||||
}
|
||||
64
frontend/src/app/services/notification.service.ts
Normal file
64
frontend/src/app/services/notification.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NotificationService {
|
||||
|
||||
constructor(private snackBar: MatSnackBar) {}
|
||||
|
||||
showSuccess(message: string, duration: number = 3000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['success-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showError(message: string, duration: number = 5000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['error-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showInfo(message: string, duration: number = 3000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['info-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showWarning(message: string, duration: number = 4000): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration,
|
||||
panelClass: ['warning-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
showConfirmation(message: string, action: string = 'Confirm'): Promise<boolean> {
|
||||
const snackBarRef = this.snackBar.open(message, action, {
|
||||
duration: 10000,
|
||||
panelClass: ['confirmation-snackbar'],
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom'
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
snackBarRef.onAction().subscribe(() => resolve(true));
|
||||
snackBarRef.afterDismissed().subscribe((info) => {
|
||||
if (!info.dismissedByAction) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
15
frontend/src/index.html
Normal file
15
frontend/src/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frontend</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -524,6 +524,49 @@ code {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Artifact link styles - softer blue */
|
||||
.artifact-link {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.artifact-link:hover {
|
||||
color: #bfdbfe;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Clickable row cursor */
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Search icon color */
|
||||
.search-icon {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Material Icons */
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 20px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.material-icons.md-16 { font-size: 16px; }
|
||||
.material-icons.md-18 { font-size: 18px; }
|
||||
.material-icons.md-20 { font-size: 20px; }
|
||||
.material-icons.md-24 { font-size: 24px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
70
frontend/src/styles.scss
Normal file
70
frontend/src/styles.scss
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Global styles for Obsidian - Dark Theme from main branch */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Material Snackbar Styles */
|
||||
.success-snackbar {
|
||||
background-color: #4caf50 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.error-snackbar {
|
||||
background-color: #f44336 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.info-snackbar {
|
||||
background-color: #2196f3 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.warning-snackbar {
|
||||
background-color: #ff9800 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.confirmation-snackbar {
|
||||
background-color: #673ab7 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Main color variables matching main branch */
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dark-muted: #64748b;
|
||||
--accent-blue: #60a5fa;
|
||||
--accent-blue-dark: #3b82f6;
|
||||
--accent-blue-darker: #2563eb;
|
||||
--gradient-start: #1e3a8a;
|
||||
--gradient-end: #4338ca;
|
||||
--success-bg: #064e3b;
|
||||
--success-text: #6ee7b7;
|
||||
--error-bg: #7f1d1d;
|
||||
--error-text: #fca5a5;
|
||||
--warning-bg: #78350f;
|
||||
--warning-text: #fcd34d;
|
||||
--badge-bg: #1e3a8a;
|
||||
--badge-text: #93c5fd;
|
||||
}
|
||||
34
frontend/switch-registry.ps1
Normal file
34
frontend/switch-registry.ps1
Normal file
@@ -0,0 +1,34 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet("public", "artifactory")]
|
||||
[string]$RegistryType = "public"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
switch ($RegistryType) {
|
||||
"public" {
|
||||
Write-Host "Switching to public npm registry..." -ForegroundColor Yellow
|
||||
Copy-Item ".npmrc.public" ".npmrc" -Force
|
||||
Write-Host "[OK] Now using registry.npmjs.org" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "To install dependencies:" -ForegroundColor White
|
||||
Write-Host " npm ci --force" -ForegroundColor Cyan
|
||||
}
|
||||
"artifactory" {
|
||||
Write-Host "Switching to Artifactory registry..." -ForegroundColor Yellow
|
||||
Copy-Item ".npmrc.artifactory" ".npmrc" -Force
|
||||
Write-Host "[OK] Now using Artifactory registry" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Make sure to set environment variables if authentication is required:" -ForegroundColor White
|
||||
Write-Host ' $env:ARTIFACTORY_AUTH_TOKEN = "your_token"' -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "To install dependencies:" -ForegroundColor White
|
||||
Write-Host " npm ci --force" -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Current .npmrc contents:" -ForegroundColor White
|
||||
Get-Content ".npmrc"
|
||||
42
frontend/switch-registry.sh
Normal file
42
frontend/switch-registry.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to switch between npm registries
|
||||
# Usage: ./switch-registry.sh [public|artifactory]
|
||||
|
||||
set -e
|
||||
|
||||
REGISTRY_TYPE=${1:-public}
|
||||
|
||||
case $REGISTRY_TYPE in
|
||||
public)
|
||||
echo "Switching to public npm registry..."
|
||||
cp .npmrc.public .npmrc
|
||||
echo "✓ Now using registry.npmjs.org"
|
||||
echo ""
|
||||
echo "To install dependencies:"
|
||||
echo " npm ci --force"
|
||||
;;
|
||||
artifactory)
|
||||
echo "Switching to Artifactory registry..."
|
||||
cp .npmrc.artifactory .npmrc
|
||||
echo "✓ Now using Artifactory registry"
|
||||
echo ""
|
||||
echo "Make sure to set environment variables if authentication is required:"
|
||||
echo " export ARTIFACTORY_AUTH_TOKEN=your_token"
|
||||
echo ""
|
||||
echo "To install dependencies:"
|
||||
echo " npm ci --force"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [public|artifactory]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " public - Use registry.npmjs.org (default)"
|
||||
echo " artifactory - Use Artifactory npm registry"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "Current .npmrc contents:"
|
||||
cat .npmrc
|
||||
15
frontend/tsconfig.app.json
Normal file
15
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
15
frontend/tsconfig.spec.json
Normal file
15
frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
76
nginx.conf
Normal file
76
nginx.conf
Normal file
@@ -0,0 +1,76 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Serve Angular app
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy docs requests to backend
|
||||
location /docs {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy redoc requests to backend
|
||||
location /redoc {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy health check to backend
|
||||
location /health {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
quickstart.bat
106
quickstart.bat
@@ -1,106 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo =========================================
|
||||
echo Obsidian - Quick Start
|
||||
echo =========================================
|
||||
echo.
|
||||
|
||||
REM Check if Docker is installed
|
||||
where docker >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Docker is not installed. Please install Docker Desktop first.
|
||||
echo Visit: https://www.docker.com/products/docker-desktop
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Check if Docker Compose is available
|
||||
where docker-compose >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
REM Try docker compose (new version)
|
||||
docker compose version >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Docker Compose is not available.
|
||||
echo Please ensure Docker Desktop is running.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set COMPOSE_CMD=docker compose
|
||||
) else (
|
||||
set COMPOSE_CMD=docker-compose
|
||||
)
|
||||
|
||||
REM Create .env file if it doesn't exist
|
||||
if not exist .env (
|
||||
echo Creating .env file from .env.example...
|
||||
copy .env.example .env >nul
|
||||
echo [OK] .env file created
|
||||
) else (
|
||||
echo [OK] .env file already exists
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Building and starting services with Docker Compose...
|
||||
%COMPOSE_CMD% up -d --build
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo Error: Failed to start services.
|
||||
echo Make sure Docker Desktop is running.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Waiting for services to be ready...
|
||||
timeout /t 15 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Services are running!
|
||||
echo =========================================
|
||||
echo.
|
||||
echo Web UI: http://localhost:8000
|
||||
echo API Docs: http://localhost:8000/docs
|
||||
echo MinIO Console: http://localhost:9001
|
||||
echo Username: minioadmin
|
||||
echo Password: minioadmin
|
||||
echo.
|
||||
echo To view logs: %COMPOSE_CMD% logs -f
|
||||
echo To stop: %COMPOSE_CMD% down
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Testing the API...
|
||||
echo =========================================
|
||||
echo.
|
||||
|
||||
REM Wait a bit more for API to be fully ready
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
REM Test health endpoint
|
||||
curl -s http://localhost:8000/health | findstr "healthy" >nul 2>nul
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] API is healthy!
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Open your browser to get started:
|
||||
echo http://localhost:8000
|
||||
echo =========================================
|
||||
) else (
|
||||
echo [WARNING] API is not responding yet.
|
||||
echo Please wait a moment and check http://localhost:8000
|
||||
)
|
||||
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Setup complete!
|
||||
echo =========================================
|
||||
echo.
|
||||
echo Press any key to open the UI in your browser...
|
||||
pause >nul
|
||||
|
||||
REM Open browser
|
||||
start http://localhost:8000
|
||||
|
||||
exit /b 0
|
||||
215
quickstart.ps1
215
quickstart.ps1
@@ -1,129 +1,196 @@
|
||||
# Test Artifact Data Lake - Quick Start (PowerShell)
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Rebuild,
|
||||
[switch]$Bsf,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Obsidian - Quick Start" -ForegroundColor Cyan
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Usage: .\quickstart.ps1 [OPTIONS]" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Options:" -ForegroundColor Yellow
|
||||
Write-Host " -Rebuild Force rebuild of all containers" -ForegroundColor White
|
||||
Write-Host " -Bsf Use Artifactory npm registry instead of public npm" -ForegroundColor White
|
||||
Write-Host " -Help Show this help message" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Environment Variables (when using -Bsf):" -ForegroundColor Yellow
|
||||
Write-Host ' $env:ARTIFACTORY_AUTH_TOKEN Authentication token for Artifactory' -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Brings up the complete stack: database, backend API, and frontend" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Obsidian - Quick Start" -ForegroundColor Cyan
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Determine npm registry and build arguments
|
||||
$NpmRegistry = "public"
|
||||
$BuildArgs = @()
|
||||
|
||||
if ($Bsf) {
|
||||
$NpmRegistry = "artifactory"
|
||||
$BuildArgs += "--build-arg"
|
||||
$BuildArgs += "NPM_REGISTRY=artifactory"
|
||||
|
||||
Write-Host "Using Artifactory npm registry" -ForegroundColor Yellow
|
||||
|
||||
if ($env:ARTIFACTORY_AUTH_TOKEN) {
|
||||
Write-Host "[OK] Artifactory auth token detected" -ForegroundColor Green
|
||||
$BuildArgs += "--build-arg"
|
||||
$BuildArgs += "ARTIFACTORY_AUTH_TOKEN=$env:ARTIFACTORY_AUTH_TOKEN"
|
||||
} else {
|
||||
Write-Host "[WARNING] ARTIFACTORY_AUTH_TOKEN not set (may be required for authentication)" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "Using public npm registry (registry.npmjs.org)" -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Check if Docker is installed
|
||||
try {
|
||||
$dockerVersion = docker --version
|
||||
Write-Host "[OK] Docker found: $dockerVersion" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[ERROR] Docker is not installed." -ForegroundColor Red
|
||||
Write-Host "Please install Docker Desktop first:" -ForegroundColor Yellow
|
||||
Write-Host "https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
|
||||
if (-not (Get-Command "docker" -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Error: Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
|
||||
Write-Host "Visit: https://www.docker.com/products/docker-desktop" -ForegroundColor Yellow
|
||||
Read-Host "Press Enter to exit"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Determine Docker Compose command
|
||||
$composeCmd = "docker-compose"
|
||||
try {
|
||||
docker-compose version | Out-Null
|
||||
} catch {
|
||||
# Try new docker compose syntax
|
||||
# Check if Docker Compose is available
|
||||
$ComposeCmd = $null
|
||||
if (Get-Command "docker-compose" -ErrorAction SilentlyContinue) {
|
||||
$ComposeCmd = "docker-compose"
|
||||
} else {
|
||||
try {
|
||||
docker compose version | Out-Null
|
||||
$composeCmd = "docker compose"
|
||||
} catch {
|
||||
Write-Host "[ERROR] Docker Compose is not available." -ForegroundColor Red
|
||||
& docker compose version | Out-Null
|
||||
$ComposeCmd = "docker compose"
|
||||
}
|
||||
catch {
|
||||
Write-Host "Error: Docker Compose is not available." -ForegroundColor Red
|
||||
Write-Host "Please ensure Docker Desktop is running." -ForegroundColor Yellow
|
||||
Read-Host "Press Enter to exit"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[OK] Using: $composeCmd" -ForegroundColor Green
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if (-Not (Test-Path ".env")) {
|
||||
if (-not (Test-Path ".env")) {
|
||||
Write-Host "Creating .env file from .env.example..." -ForegroundColor Yellow
|
||||
Copy-Item .env.example .env
|
||||
Copy-Item ".env.example" ".env"
|
||||
Write-Host "[OK] .env file created" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[OK] .env file already exists" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Building and starting services with Docker Compose..." -ForegroundColor Yellow
|
||||
|
||||
# Start services with rebuild
|
||||
if ($composeCmd -eq "docker-compose") {
|
||||
docker-compose up -d --build
|
||||
# Handle rebuild logic
|
||||
if ($Rebuild) {
|
||||
Write-Host "Rebuilding containers..." -ForegroundColor Yellow
|
||||
Write-Host "Stopping existing containers..." -ForegroundColor White
|
||||
|
||||
if ($ComposeCmd -eq "docker compose") {
|
||||
& docker compose down
|
||||
Write-Host "Removing existing images for rebuild..." -ForegroundColor White
|
||||
& docker compose down --rmi local
|
||||
Write-Host "Building and starting all services..." -ForegroundColor White
|
||||
if ($BuildArgs.Count -gt 0) {
|
||||
& docker compose build $BuildArgs
|
||||
& docker compose up -d
|
||||
} else {
|
||||
& docker compose up -d --build
|
||||
}
|
||||
} else {
|
||||
& docker-compose down
|
||||
Write-Host "Removing existing images for rebuild..." -ForegroundColor White
|
||||
& docker-compose down --rmi local
|
||||
Write-Host "Building and starting all services..." -ForegroundColor White
|
||||
if ($BuildArgs.Count -gt 0) {
|
||||
& docker-compose build $BuildArgs
|
||||
& docker-compose up -d
|
||||
} else {
|
||||
& docker-compose up -d --build
|
||||
}
|
||||
}
|
||||
} else {
|
||||
docker compose up -d --build
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Failed to start services." -ForegroundColor Red
|
||||
Write-Host "Make sure Docker Desktop is running." -ForegroundColor Yellow
|
||||
Read-Host "Press Enter to exit"
|
||||
exit 1
|
||||
Write-Host "Starting all services..." -ForegroundColor Green
|
||||
if ($ComposeCmd -eq "docker compose") {
|
||||
if ($BuildArgs.Count -gt 0) {
|
||||
& docker compose build $BuildArgs
|
||||
& docker compose up -d
|
||||
} else {
|
||||
& docker compose up -d
|
||||
}
|
||||
} else {
|
||||
if ($BuildArgs.Count -gt 0) {
|
||||
& docker-compose build $BuildArgs
|
||||
& docker-compose up -d
|
||||
} else {
|
||||
& docker-compose up -d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Waiting for services to be ready..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 15
|
||||
Start-Sleep -Seconds 20
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Services are running!" -ForegroundColor Green
|
||||
Write-Host "Complete Stack is running!" -ForegroundColor Green
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Web UI: " -NoNewline
|
||||
Write-Host "http://localhost:8000" -ForegroundColor Yellow
|
||||
Write-Host "API Docs: " -NoNewline
|
||||
Write-Host "http://localhost:8000/docs" -ForegroundColor Yellow
|
||||
Write-Host "MinIO Console: " -NoNewline
|
||||
Write-Host "http://localhost:9001" -ForegroundColor Yellow
|
||||
Write-Host " Username: minioadmin"
|
||||
Write-Host " Password: minioadmin"
|
||||
Write-Host "Application: http://localhost:8000" -ForegroundColor White
|
||||
Write-Host "API Docs: http://localhost:8000/docs" -ForegroundColor White
|
||||
Write-Host "MinIO Console: http://localhost:9001" -ForegroundColor White
|
||||
Write-Host " Username: minioadmin" -ForegroundColor Gray
|
||||
Write-Host " Password: minioadmin" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "To view logs: $composeCmd logs -f" -ForegroundColor Cyan
|
||||
Write-Host "To stop: $composeCmd down" -ForegroundColor Cyan
|
||||
Write-Host "To view logs: $ComposeCmd logs -f" -ForegroundColor Yellow
|
||||
Write-Host "To stop: $ComposeCmd down" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Testing the API..." -ForegroundColor Yellow
|
||||
Write-Host "Testing the API..." -ForegroundColor Cyan
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Wait a bit more for API
|
||||
# Wait a bit more for API to be fully ready
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
# Test health endpoint
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "http://localhost:8000/health" -UseBasicParsing -TimeoutSec 5
|
||||
if ($response.Content -like "*healthy*") {
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:8000/health" -Method Get -TimeoutSec 10
|
||||
if ($response.status -eq "healthy") {
|
||||
Write-Host "[OK] API is healthy!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Opening browser..." -ForegroundColor Yellow
|
||||
Write-Host "http://localhost:8000" -ForegroundColor Yellow
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
|
||||
# Open browser
|
||||
Start-Process "http://localhost:8000"
|
||||
Write-Host "All services are ready!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Example: Upload a test file" -ForegroundColor White
|
||||
Write-Host "----------------------------" -ForegroundColor Gray
|
||||
Write-Host 'echo "test,data" > test.csv' -ForegroundColor Green
|
||||
Write-Host 'curl -X POST "http://localhost:8000/api/v1/artifacts/upload"' -ForegroundColor Green
|
||||
Write-Host ' -F "file=@test.csv"' -ForegroundColor Green
|
||||
Write-Host ' -F "test_name=sample_test"' -ForegroundColor Green
|
||||
Write-Host ' -F "test_suite=demo"' -ForegroundColor Green
|
||||
Write-Host ' -F "test_result=pass"' -ForegroundColor Green
|
||||
Write-Host ""
|
||||
} else {
|
||||
Write-Host "[WARNING] API returned unexpected status. Please check http://localhost:8000/health" -ForegroundColor Yellow
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[WARNING] API is not responding yet." -ForegroundColor Yellow
|
||||
Write-Host "Please wait a moment and check http://localhost:8000" -ForegroundColor Yellow
|
||||
}
|
||||
catch {
|
||||
Write-Host "[WARNING] API is not responding yet. Please wait a moment and check http://localhost:8000/health" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Setup complete! " -NoNewline
|
||||
Write-Host "🚀" -ForegroundColor Green
|
||||
Write-Host "Setup complete!" -ForegroundColor Green
|
||||
Write-Host "Open http://localhost:8000 in your browser" -ForegroundColor Yellow
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Useful Commands:" -ForegroundColor Cyan
|
||||
Write-Host " Generate seed data: " -NoNewline
|
||||
Write-Host "Use the 'Generate Seed Data' button in the UI" -ForegroundColor Yellow
|
||||
Write-Host " View logs: " -NoNewline
|
||||
Write-Host "$composeCmd logs -f api" -ForegroundColor Yellow
|
||||
Write-Host " Restart services: " -NoNewline
|
||||
Write-Host "$composeCmd restart" -ForegroundColor Yellow
|
||||
Write-Host " Stop all: " -NoNewline
|
||||
Write-Host "$composeCmd down" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
101
quickstart.sh
Executable file → Normal file
101
quickstart.sh
Executable file → Normal file
@@ -14,11 +14,71 @@ if ! command -v docker &> /dev/null; then
|
||||
fi
|
||||
|
||||
# Check if Docker Compose is installed
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD=""
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
elif docker compose version &> /dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
else
|
||||
echo "Error: Docker Compose is not installed. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse command line arguments
|
||||
REBUILD=false
|
||||
USE_ARTIFACTORY=false
|
||||
NPM_REGISTRY="public"
|
||||
BUILD_ARGS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--rebuild)
|
||||
REBUILD=true
|
||||
shift
|
||||
;;
|
||||
-bsf)
|
||||
USE_ARTIFACTORY=true
|
||||
NPM_REGISTRY="artifactory"
|
||||
BUILD_ARGS="--build-arg NPM_REGISTRY=artifactory"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --rebuild Force rebuild of all containers"
|
||||
echo " -bsf Use Artifactory npm registry instead of public npm"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Environment Variables (when using -bsf):"
|
||||
echo " ARTIFACTORY_AUTH_TOKEN Authentication token for Artifactory"
|
||||
echo ""
|
||||
echo "Brings up the complete stack: database, backend API, and frontend"
|
||||
echo ""
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# If using Artifactory, add auth token to build args if available
|
||||
if [ "$USE_ARTIFACTORY" = true ]; then
|
||||
echo "Using Artifactory npm registry"
|
||||
if [ -n "$ARTIFACTORY_AUTH_TOKEN" ]; then
|
||||
echo "✓ Artifactory auth token detected"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg ARTIFACTORY_AUTH_TOKEN=$ARTIFACTORY_AUTH_TOKEN"
|
||||
else
|
||||
echo "⚠ Warning: ARTIFACTORY_AUTH_TOKEN not set (may be required for authentication)"
|
||||
fi
|
||||
else
|
||||
echo "Using public npm registry (registry.npmjs.org)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file from .env.example..."
|
||||
@@ -29,26 +89,48 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building and starting services with Docker Compose..."
|
||||
docker-compose up -d --build
|
||||
|
||||
# Stop existing containers if rebuild is requested
|
||||
if [ "$REBUILD" = true ]; then
|
||||
echo "🔄 Rebuilding containers..."
|
||||
echo "Stopping existing containers..."
|
||||
$COMPOSE_CMD down
|
||||
echo "Removing existing images for rebuild..."
|
||||
$COMPOSE_CMD down --rmi local 2>/dev/null || true
|
||||
echo "Building and starting all services..."
|
||||
if [ -n "$BUILD_ARGS" ]; then
|
||||
$COMPOSE_CMD build $BUILD_ARGS
|
||||
$COMPOSE_CMD up -d
|
||||
else
|
||||
$COMPOSE_CMD up -d --build
|
||||
fi
|
||||
else
|
||||
echo "Starting all services..."
|
||||
if [ -n "$BUILD_ARGS" ]; then
|
||||
$COMPOSE_CMD build $BUILD_ARGS
|
||||
$COMPOSE_CMD up -d
|
||||
else
|
||||
$COMPOSE_CMD up -d
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Waiting for services to be ready..."
|
||||
sleep 10
|
||||
sleep 20
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Services are running!"
|
||||
echo "Complete Stack is running! 🚀"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "API: http://localhost:8000"
|
||||
echo "Application: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo "MinIO Console: http://localhost:9001"
|
||||
echo " Username: minioadmin"
|
||||
echo " Password: minioadmin"
|
||||
echo ""
|
||||
echo "To view logs: docker-compose logs -f"
|
||||
echo "To stop: docker-compose down"
|
||||
echo "To view logs: $COMPOSE_CMD logs -f"
|
||||
echo "To stop: $COMPOSE_CMD down"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Testing the API..."
|
||||
@@ -62,6 +144,8 @@ sleep 5
|
||||
if curl -s http://localhost:8000/health | grep -q "healthy"; then
|
||||
echo "✓ API is healthy!"
|
||||
echo ""
|
||||
echo "🎯 All services are ready!"
|
||||
echo ""
|
||||
echo "Example: Upload a test file"
|
||||
echo "----------------------------"
|
||||
echo 'echo "test,data" > test.csv'
|
||||
@@ -77,4 +161,5 @@ fi
|
||||
|
||||
echo "========================================="
|
||||
echo "Setup complete! 🚀"
|
||||
echo "Open http://localhost:8000 in your browser"
|
||||
echo "========================================="
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user