Compare commits
26 Commits
main
...
14d85614ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d85614ed | ||
|
|
7c1346caac | ||
|
|
dcd782ad1a | ||
|
|
9937211b2d | ||
|
|
302b6c1bf1 | ||
|
|
51c0ebd862 | ||
|
|
aae317edc1 | ||
|
|
ca0141a5b3 | ||
|
|
e2d26d7186 | ||
|
|
82d2cb5768 | ||
|
|
b4de8757e6 | ||
| ace9eb82db | |||
| a16c4f9898 | |||
| c7abff0c92 | |||
| 17331d9808 | |||
| 9a37c1f549 | |||
| 8f2e89ba98 | |||
| 95aec1cc5c | |||
| 578ddba690 | |||
| 872464dec2 | |||
| 629e3e4125 | |||
| 0e1a342917 | |||
| 6f7e9a517e | |||
| 5fdefecbd0 | |||
| d5c8bd188b | |||
| ed5773893e |
@@ -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=
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -86,3 +86,10 @@ helm/charts/
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Node.js
|
||||
package-lock.json
|
||||
**/package-lock.json
|
||||
|
||||
# Built static files (generated during Docker build from Angular)
|
||||
static/
|
||||
|
||||
163
.gitlab-ci.yml
163
.gitlab-ci.yml
@@ -1,164 +1,13 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
LATEST_TAG: $CI_REGISTRY_IMAGE:latest
|
||||
|
||||
# Test stage
|
||||
test:
|
||||
stage: test
|
||||
image: python:3.11-slim
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y gcc postgresql-client
|
||||
- pip install -r requirements.txt
|
||||
- pip install pytest pytest-asyncio httpx
|
||||
image: containers.global.bsf.tools/node:20.11-alpine3.19
|
||||
script:
|
||||
- echo "Running tests..."
|
||||
- python -m pytest tests/ -v || echo "No tests found, skipping"
|
||||
only:
|
||||
- branches
|
||||
- merge_requests
|
||||
|
||||
# Lint stage
|
||||
lint:
|
||||
stage: test
|
||||
image: python:3.11-slim
|
||||
before_script:
|
||||
- pip install flake8 black
|
||||
script:
|
||||
- echo "Running linters..."
|
||||
- flake8 app/ --max-line-length=120 --ignore=E203,W503 || true
|
||||
- black --check app/ || true
|
||||
only:
|
||||
- branches
|
||||
- merge_requests
|
||||
allow_failure: true
|
||||
|
||||
# Build Docker image
|
||||
build:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:24-dind
|
||||
before_script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
script:
|
||||
- echo "Building Docker image..."
|
||||
- docker build -t $IMAGE_TAG -t $LATEST_TAG .
|
||||
- docker push $IMAGE_TAG
|
||||
- docker push $LATEST_TAG
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
- tags
|
||||
|
||||
# Deploy to development
|
||||
deploy:dev:
|
||||
stage: deploy
|
||||
image: alpine/helm:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
- chmod +x kubectl
|
||||
- mv kubectl /usr/local/bin/
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBE_CONFIG_DEV" | base64 -d > ~/.kube/config
|
||||
script:
|
||||
- echo "Deploying to development environment..."
|
||||
- |
|
||||
helm upgrade --install datalake-dev ./helm \
|
||||
--namespace datalake-dev \
|
||||
--create-namespace \
|
||||
--set image.repository=$CI_REGISTRY_IMAGE \
|
||||
--set image.tag=$CI_COMMIT_SHORT_SHA \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake-dev.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--wait \
|
||||
--timeout 5m
|
||||
environment:
|
||||
name: development
|
||||
url: https://datalake-dev.example.com
|
||||
only:
|
||||
- develop
|
||||
when: manual
|
||||
|
||||
# Deploy to staging
|
||||
deploy:staging:
|
||||
stage: deploy
|
||||
image: alpine/helm:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
- chmod +x kubectl
|
||||
- mv kubectl /usr/local/bin/
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBE_CONFIG_STAGING" | base64 -d > ~/.kube/config
|
||||
script:
|
||||
- echo "Deploying to staging environment..."
|
||||
- |
|
||||
helm upgrade --install datalake-staging ./helm \
|
||||
--namespace datalake-staging \
|
||||
--create-namespace \
|
||||
--set image.repository=$CI_REGISTRY_IMAGE \
|
||||
--set image.tag=$CI_COMMIT_SHORT_SHA \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake-staging.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--set resources.requests.cpu=1000m \
|
||||
--set resources.requests.memory=1Gi \
|
||||
--wait \
|
||||
--timeout 5m
|
||||
environment:
|
||||
name: staging
|
||||
url: https://datalake-staging.example.com
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
when: manual
|
||||
|
||||
# Deploy to production
|
||||
deploy:prod:
|
||||
stage: deploy
|
||||
image: alpine/helm:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
- chmod +x kubectl
|
||||
- mv kubectl /usr/local/bin/
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBE_CONFIG_PROD" | base64 -d > ~/.kube/config
|
||||
script:
|
||||
- echo "Deploying to production environment..."
|
||||
- |
|
||||
helm upgrade --install datalake ./helm \
|
||||
--namespace datalake-prod \
|
||||
--create-namespace \
|
||||
--set image.repository=$CI_REGISTRY_IMAGE \
|
||||
--set image.tag=$CI_COMMIT_SHORT_SHA \
|
||||
--set replicaCount=3 \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--set resources.requests.cpu=2000m \
|
||||
--set resources.requests.memory=2Gi \
|
||||
--set autoscaling.enabled=true \
|
||||
--set autoscaling.minReplicas=3 \
|
||||
--set autoscaling.maxReplicas=10 \
|
||||
--wait \
|
||||
--timeout 10m
|
||||
environment:
|
||||
name: production
|
||||
url: https://datalake.example.com
|
||||
only:
|
||||
- tags
|
||||
when: manual
|
||||
- cd frontend
|
||||
- npm config set registry https://deps.global.bsf.tools/artifactory/api/npm/registry.npmjs.org/
|
||||
- npm config set strict-ssl false
|
||||
- npm config fix
|
||||
- npm install
|
||||
164
.gitlab-ci.yml.old
Normal file
164
.gitlab-ci.yml.old
Normal file
@@ -0,0 +1,164 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
LATEST_TAG: $CI_REGISTRY_IMAGE:latest
|
||||
|
||||
# Test stage
|
||||
test:
|
||||
stage: test
|
||||
image: python:3.11-slim
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y gcc postgresql-client
|
||||
- pip install -r requirements.txt
|
||||
- pip install pytest pytest-asyncio httpx
|
||||
script:
|
||||
- echo "Running tests..."
|
||||
- python -m pytest tests/ -v || echo "No tests found, skipping"
|
||||
only:
|
||||
- branches
|
||||
- merge_requests
|
||||
|
||||
# Lint stage
|
||||
lint:
|
||||
stage: test
|
||||
image: python:3.11-slim
|
||||
before_script:
|
||||
- pip install flake8 black
|
||||
script:
|
||||
- echo "Running linters..."
|
||||
- flake8 app/ --max-line-length=120 --ignore=E203,W503 || true
|
||||
- black --check app/ || true
|
||||
only:
|
||||
- branches
|
||||
- merge_requests
|
||||
allow_failure: true
|
||||
|
||||
# Build Docker image
|
||||
build:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:24-dind
|
||||
before_script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
script:
|
||||
- echo "Building Docker image..."
|
||||
- docker build -t $IMAGE_TAG -t $LATEST_TAG .
|
||||
- docker push $IMAGE_TAG
|
||||
- docker push $LATEST_TAG
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
- tags
|
||||
|
||||
# Deploy to development
|
||||
deploy:dev:
|
||||
stage: deploy
|
||||
image: alpine/helm:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
- chmod +x kubectl
|
||||
- mv kubectl /usr/local/bin/
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBE_CONFIG_DEV" | base64 -d > ~/.kube/config
|
||||
script:
|
||||
- echo "Deploying to development environment..."
|
||||
- |
|
||||
helm upgrade --install datalake-dev ./helm \
|
||||
--namespace datalake-dev \
|
||||
--create-namespace \
|
||||
--set image.repository=$CI_REGISTRY_IMAGE \
|
||||
--set image.tag=$CI_COMMIT_SHORT_SHA \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake-dev.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--wait \
|
||||
--timeout 5m
|
||||
environment:
|
||||
name: development
|
||||
url: https://datalake-dev.example.com
|
||||
only:
|
||||
- develop
|
||||
when: manual
|
||||
|
||||
# Deploy to staging
|
||||
deploy:staging:
|
||||
stage: deploy
|
||||
image: alpine/helm:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
- chmod +x kubectl
|
||||
- mv kubectl /usr/local/bin/
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBE_CONFIG_STAGING" | base64 -d > ~/.kube/config
|
||||
script:
|
||||
- echo "Deploying to staging environment..."
|
||||
- |
|
||||
helm upgrade --install datalake-staging ./helm \
|
||||
--namespace datalake-staging \
|
||||
--create-namespace \
|
||||
--set image.repository=$CI_REGISTRY_IMAGE \
|
||||
--set image.tag=$CI_COMMIT_SHORT_SHA \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake-staging.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--set resources.requests.cpu=1000m \
|
||||
--set resources.requests.memory=1Gi \
|
||||
--wait \
|
||||
--timeout 5m
|
||||
environment:
|
||||
name: staging
|
||||
url: https://datalake-staging.example.com
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
when: manual
|
||||
|
||||
# Deploy to production
|
||||
deploy:prod:
|
||||
stage: deploy
|
||||
image: alpine/helm:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
- chmod +x kubectl
|
||||
- mv kubectl /usr/local/bin/
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBE_CONFIG_PROD" | base64 -d > ~/.kube/config
|
||||
script:
|
||||
- echo "Deploying to production environment..."
|
||||
- |
|
||||
helm upgrade --install datalake ./helm \
|
||||
--namespace datalake-prod \
|
||||
--create-namespace \
|
||||
--set image.repository=$CI_REGISTRY_IMAGE \
|
||||
--set image.tag=$CI_COMMIT_SHORT_SHA \
|
||||
--set replicaCount=3 \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=datalake.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--set resources.requests.cpu=2000m \
|
||||
--set resources.requests.memory=2Gi \
|
||||
--set autoscaling.enabled=true \
|
||||
--set autoscaling.minReplicas=3 \
|
||||
--set autoscaling.maxReplicas=10 \
|
||||
--wait \
|
||||
--timeout 10m
|
||||
environment:
|
||||
name: production
|
||||
url: https://datalake.example.com
|
||||
only:
|
||||
- tags
|
||||
when: manual
|
||||
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
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,3 +1,30 @@
|
||||
# Multi-stage build: First stage builds Angular frontend
|
||||
FROM node:24-alpine AS frontend-build
|
||||
|
||||
# Accept npm registry as build argument
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Configure npm registry if custom registry is provided
|
||||
RUN if [ "$NPM_REGISTRY" != "https://registry.npmjs.org/" ]; then \
|
||||
echo "Using custom npm registry: $NPM_REGISTRY"; \
|
||||
npm config set registry "$NPM_REGISTRY"; \
|
||||
fi
|
||||
|
||||
# Install dependencies (ignore package-lock.json if using custom registry)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build for production
|
||||
RUN npm run build:prod
|
||||
|
||||
# Second stage: Python backend with Angular frontend
|
||||
FROM python:3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
@@ -20,7 +47,9 @@ COPY app/ ./app/
|
||||
COPY utils/ ./utils/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
COPY static/ ./static/
|
||||
|
||||
# Copy built Angular frontend from first stage to static directory
|
||||
COPY --from=frontend-build /frontend/dist/frontend/browser ./static/
|
||||
|
||||
# Create non-root user (Alpine uses adduser instead of useradd)
|
||||
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
|
||||
|
||||
19
README.md
19
README.md
@@ -1,4 +1,4 @@
|
||||
# Obsidian
|
||||
# Warehouse13
|
||||
|
||||
**Enterprise Test Artifact Storage**
|
||||
|
||||
@@ -36,7 +36,7 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-Command Setup
|
||||
### Standard Deployment (Internet Access)
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
@@ -48,11 +48,20 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
|
||||
.\quickstart.ps1
|
||||
```
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```batch
|
||||
quickstart.bat
|
||||
### Air-Gapped/Restricted Environment Deployment
|
||||
|
||||
**For environments with restricted npm access:**
|
||||
```bash
|
||||
./quickstart-airgap.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Builds Angular locally (where npm works)
|
||||
2. Packages pre-built files into Docker
|
||||
3. Starts all services
|
||||
|
||||
See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for detailed instructions.
|
||||
|
||||
### Manual Setup with Docker Compose
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
36
app/main.py
36
app/main.py
@@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Obsidian",
|
||||
title="Warehouse13",
|
||||
description="Enterprise Test Artifact Storage - API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
@@ -39,10 +39,8 @@ app.add_middleware(
|
||||
app.include_router(artifacts_router)
|
||||
app.include_router(seed_router)
|
||||
|
||||
# Mount static files
|
||||
# Static directory setup
|
||||
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")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -59,7 +57,7 @@ async def startup_event():
|
||||
async def api_root():
|
||||
"""API root endpoint"""
|
||||
return {
|
||||
"message": "Obsidian - Enterprise Test Artifact Storage",
|
||||
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"deployment_mode": settings.deployment_mode,
|
||||
@@ -75,7 +73,31 @@ async def ui_root():
|
||||
return FileResponse(index_path)
|
||||
else:
|
||||
return {
|
||||
"message": "Obsidian - Enterprise Test Artifact Storage",
|
||||
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"ui": "UI not found. Serving API only.",
|
||||
"deployment_mode": settings.deployment_mode,
|
||||
"storage_backend": settings.storage_backend
|
||||
}
|
||||
|
||||
|
||||
# Catch-all route for Angular SPA routing - must be last
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
"""Serve Angular SPA static files and handle client-side routing"""
|
||||
# Try to serve static file first (JS, CSS, images, etc.)
|
||||
file_path = os.path.join(static_dir, full_path)
|
||||
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
|
||||
# For all other routes (Angular client-side routes), serve index.html
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
else:
|
||||
return {
|
||||
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"ui": "UI not found. Serving API only.",
|
||||
|
||||
62
check-ready.sh
Executable file
62
check-ready.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "========================================="
|
||||
echo "Warehouse13 - Readiness Check"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
errors=0
|
||||
|
||||
# Check if pre-built files exist
|
||||
if [ -d "frontend/dist/frontend/browser" ]; then
|
||||
echo "✓ Pre-built Angular files found"
|
||||
echo " Location: frontend/dist/frontend/browser"
|
||||
file_count=$(ls -1 frontend/dist/frontend/browser | wc -l)
|
||||
echo " Files: $file_count"
|
||||
else
|
||||
echo "✗ ERROR: Pre-built Angular files NOT found!"
|
||||
echo " Expected: frontend/dist/frontend/browser"
|
||||
echo ""
|
||||
echo " You need to build the Angular app first:"
|
||||
echo " Run: ./build-for-airgap.sh"
|
||||
echo " OR: cd frontend && npm install && npm run build:prod"
|
||||
echo ""
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Check if docker-compose.yml is configured for pre-built
|
||||
if grep -q "Dockerfile.frontend.prebuilt" docker-compose.yml; then
|
||||
echo "✓ docker-compose.yml configured for pre-built deployment"
|
||||
else
|
||||
echo "⚠ WARNING: docker-compose.yml may not be configured for pre-built deployment"
|
||||
echo " Current frontend dockerfile:"
|
||||
grep "dockerfile:" docker-compose.yml | grep -A 1 "frontend:" | tail -1
|
||||
echo ""
|
||||
echo " For air-gapped deployment, it should be: Dockerfile.frontend.prebuilt"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Check if Docker is running
|
||||
if docker info &> /dev/null; then
|
||||
echo "✓ Docker is running"
|
||||
else
|
||||
echo "✗ ERROR: Docker is not running or not accessible"
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
|
||||
if [ $errors -eq 0 ]; then
|
||||
echo "✓ Ready to deploy!"
|
||||
echo ""
|
||||
echo "Run: docker-compose up -d --build"
|
||||
echo "Or: ./quickstart-airgap.sh"
|
||||
exit 0
|
||||
else
|
||||
echo "✗ NOT ready - please fix the errors above"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,5 +1,7 @@
|
||||
version: '3.8'
|
||||
|
||||
name: warehouse13
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
@@ -34,7 +36,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
app:
|
||||
container_name: warehouse13-app
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
140
docs/DEPLOYMENT.md
Normal file
140
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 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) ⭐ RECOMMENDED
|
||||
|
||||
Use `Dockerfile.frontend.prebuilt` for environments with restricted npm access.
|
||||
|
||||
**Requirements:**
|
||||
- Node.js 18+ installed locally (on a machine with npm access)
|
||||
- npm installed locally
|
||||
- No internet required during Docker build
|
||||
|
||||
**Note:** This project uses Angular 17 with webpack bundler (not Vite) for better compatibility with restricted npm environments.
|
||||
|
||||
**Usage:**
|
||||
|
||||
### Quick Start (Recommended)
|
||||
```bash
|
||||
./quickstart-airgap.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Build the Angular app locally
|
||||
2. Start all Docker containers
|
||||
3. Verify the deployment
|
||||
|
||||
### Manual Steps
|
||||
|
||||
### Step 1: Build Angular app locally
|
||||
**IMPORTANT:** You MUST run this step BEFORE `docker-compose up`!
|
||||
|
||||
```bash
|
||||
# Option A: Use the helper script
|
||||
./build-for-airgap.sh
|
||||
|
||||
# Option B: Build manually
|
||||
cd frontend
|
||||
npm install # Only needed once or when dependencies change
|
||||
npm run build:prod
|
||||
cd ..
|
||||
```
|
||||
|
||||
This creates `frontend/dist/frontend/browser/` which Docker will copy.
|
||||
|
||||
### 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
|
||||
|
||||
### Build Tool Package Issues
|
||||
|
||||
If you see errors about missing packages like:
|
||||
```
|
||||
Cannot find package "vite"
|
||||
Cannot find package "esbuild"
|
||||
Cannot find package "rollup"
|
||||
```
|
||||
|
||||
**Solution:** This project uses Angular 17 with webpack bundler specifically to avoid these issues. If you still encounter package access problems in your restricted environment, use Option 2 (Pre-built) deployment above, which eliminates all npm dependencies in Docker.
|
||||
|
||||
### Custom NPM Registry
|
||||
|
||||
For both options, you can use a custom npm registry:
|
||||
|
||||
```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) ⭐ **RECOMMENDED**
|
||||
- **CI/CD**: Use Option 2 for faster, more reliable builds
|
||||
- **Restricted npm access**: Use Option 2 (pre-built) ⭐ **REQUIRED**
|
||||
|
||||
---
|
||||
|
||||
## Build Strategy for Restricted Environments
|
||||
|
||||
**This project uses Angular 17 with webpack** instead of Angular 19 with Vite specifically for better compatibility with restricted npm environments. Webpack has fewer platform-specific binary dependencies than Vite.
|
||||
|
||||
If you encounter any package access errors during builds:
|
||||
- `Cannot find package "vite"`
|
||||
- `Cannot find package "rollup"`
|
||||
- `Cannot find package "esbuild"`
|
||||
- Any platform-specific binary errors
|
||||
|
||||
**Solution:** Use Option 2 (Pre-built) deployment. This completely avoids npm installation in Docker and eliminates all build tool dependency issues.
|
||||
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
|
||||
43
frontend/.gitignore
vendored
Normal file
43
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
178
frontend/README.md
Normal file
178
frontend/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Warehouse13 Frontend - Angular Application
|
||||
|
||||
Modern Angular application for the Warehouse13 Test Artifact Data Lake.
|
||||
|
||||
## Features
|
||||
|
||||
- **Angular 20** with standalone components
|
||||
- **TypeScript** for type safety
|
||||
- **Reactive Forms** for upload and query functionality
|
||||
- **RxJS** for reactive programming
|
||||
- **Auto-refresh** artifacts every 5 seconds
|
||||
- **Client-side sorting and filtering**
|
||||
- **Dark theme** UI
|
||||
- **Responsive design**
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 24.x or higher
|
||||
- npm 11.x or higher
|
||||
- Backend API running on port 8000
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run Development Server
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:4200/`
|
||||
|
||||
The development server includes a proxy configuration that forwards `/api` requests to `http://localhost:8000`.
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build:prod
|
||||
```
|
||||
|
||||
Build artifacts will be in the `dist/frontend/browser` directory.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── components/
|
||||
│ │ ├── artifacts-list/ # Main artifacts table with sorting, filtering, auto-refresh
|
||||
│ │ ├── upload-form/ # Reactive form for uploading artifacts
|
||||
│ │ └── query-form/ # Advanced query interface
|
||||
│ ├── models/
|
||||
│ │ └── artifact.model.ts # TypeScript interfaces for type safety
|
||||
│ ├── services/
|
||||
│ │ └── artifact.ts # HTTP service for API calls
|
||||
│ ├── app.ts # Main app component with routing
|
||||
│ ├── app.config.ts # Application configuration
|
||||
│ └── app.routes.ts # Route definitions
|
||||
├── styles.css # Global dark theme styles
|
||||
└── main.ts # Application bootstrap
|
||||
|
||||
## Key Components
|
||||
|
||||
### ArtifactsListComponent
|
||||
- Displays artifacts in a sortable, filterable table
|
||||
- Auto-refreshes every 5 seconds (toggleable)
|
||||
- Client-side search across all fields
|
||||
- Download and delete actions
|
||||
- Detail modal for full artifact information
|
||||
- Tags displayed as inline badges
|
||||
- SIM source grouping support
|
||||
|
||||
### UploadFormComponent
|
||||
- Reactive form with validation
|
||||
- File upload with drag-and-drop support
|
||||
- Required fields: File, Sim Source, Uploaded By, Tags
|
||||
- Optional fields: SIM Source ID (for grouping), Test Result, Version, Description, Test Config, Custom Metadata
|
||||
- JSON validation for config fields
|
||||
- Real-time upload status feedback
|
||||
|
||||
### QueryFormComponent
|
||||
- Advanced search with multiple filter criteria
|
||||
- Filter by: filename, file type, test name, test suite, test result, SIM source ID, tags, date range
|
||||
- Results emitted to artifacts list
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend communicates with the FastAPI backend through the `ArtifactService`:
|
||||
|
||||
- `GET /api/v1/artifacts/` - List all artifacts
|
||||
- `GET /api/v1/artifacts/:id` - Get single artifact
|
||||
- `POST /api/v1/artifacts/upload` - Upload new artifact
|
||||
- `POST /api/v1/artifacts/query` - Query with filters
|
||||
- `GET /api/v1/artifacts/:id/download` - Download artifact file
|
||||
- `DELETE /api/v1/artifacts/:id` - Delete artifact
|
||||
- `POST /api/v1/seed/generate/:count` - Generate seed data
|
||||
|
||||
## Configuration
|
||||
|
||||
### Proxy Configuration (`proxy.conf.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This proxies all `/api` requests to the backend during development.
|
||||
|
||||
## Styling
|
||||
|
||||
The application uses a custom dark theme with:
|
||||
- Dark blue/slate color palette
|
||||
- Gradient headers
|
||||
- Responsive design
|
||||
- Smooth transitions and hover effects
|
||||
- Tag badges for categorization
|
||||
- Result badges for test statuses
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Edge (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
|
||||
## Development Tips
|
||||
|
||||
1. **Hot Reload**: Changes to TypeScript files automatically trigger recompilation
|
||||
2. **Type Safety**: Use TypeScript interfaces in `models/` for all API responses
|
||||
3. **State Management**: Currently using component-level state; consider NgRx for complex state
|
||||
4. **Testing**: Run `npm test` for unit tests (Jasmine/Karma)
|
||||
|
||||
## Deployment
|
||||
|
||||
For production deployment, build the application and serve the `dist/frontend/browser` directory with your web server (nginx, Apache, etc.).
|
||||
|
||||
Example nginx configuration:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
root /path/to/dist/frontend/browser;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add NgRx for state management
|
||||
- [ ] Implement WebSocket for real-time updates
|
||||
- [ ] Add Angular Material components
|
||||
- [ ] Unit and E2E tests
|
||||
- [ ] PWA support
|
||||
- [ ] Drag-and-drop file upload
|
||||
- [ ] Bulk operations
|
||||
- [ ] Export to CSV/JSON
|
||||
|
||||
## License
|
||||
|
||||
Same as parent project
|
||||
100
frontend/angular.json
Normal file
100
frontend/angular.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization" : {
|
||||
"fonts": false
|
||||
},
|
||||
"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/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
8999
frontend/package-lock.json
generated
Normal file
8999
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
frontend/package.json
Normal file
83
frontend/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"build:prod": "ng build --configuration production",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.1.0",
|
||||
"@angular/compiler": "^19.1.0",
|
||||
"@angular/core": "^19.1.0",
|
||||
"@angular/forms": "^19.1.0",
|
||||
"@angular/platform-browser": "^19.1.0",
|
||||
"@angular/router": "^19.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "<=19.2.7",
|
||||
"@angular/cli": "<=19.2.7",
|
||||
"@angular/compiler-cli": "^19.1.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.9.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.8.0",
|
||||
"undici-types": "7.12.0",
|
||||
"node-releases": "2.0.21",
|
||||
"node-gyp": "11.4.2",
|
||||
"tar": "7.4.3",
|
||||
"minizlib": "3.0.2",
|
||||
"immutable": "5.1.3",
|
||||
"exponential-backoff": "3.1.2",
|
||||
"emoji-regex": "10.5.0",
|
||||
"electron-to-chromium": "1.5.221",
|
||||
"caniuse-lite": "1.0.30001743"
|
||||
},
|
||||
"resolutions": {
|
||||
"undici-types": "7.12.0",
|
||||
"node-releases": "2.0.21",
|
||||
"node-gyp": "11.4.2",
|
||||
"tar": "7.4.3",
|
||||
"minizlib": "3.0.2",
|
||||
"immutable": "5.1.3",
|
||||
"exponential-backoff": "3.1.2",
|
||||
"emoji-regex": "10.5.0",
|
||||
"electron-to-chromium": "1.5.221",
|
||||
"caniuse-lite": "1.0.30001743"
|
||||
},
|
||||
"overrides": {
|
||||
"undici-types": "7.12.0",
|
||||
"node-releases": "2.0.21",
|
||||
"node-gyp": "11.4.2",
|
||||
"tar": "7.4.3",
|
||||
"minizlib": "3.0.2",
|
||||
"immutable": "5.1.3",
|
||||
"exponential-backoff": "3.1.2",
|
||||
"emoji-regex": "10.5.0",
|
||||
"electron-to-chromium": "1.5.221",
|
||||
"caniuse-lite": "1.0.30001743"
|
||||
}
|
||||
}
|
||||
7
frontend/proxy.conf.json
Normal file
7
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
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 />
|
||||
11
frontend/src/app/app.routes.ts
Normal file
11
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ArtifactsListComponent } from './components/artifacts-list/artifacts-list';
|
||||
import { UploadFormComponent } from './components/upload-form/upload-form';
|
||||
import { QueryFormComponent } from './components/query-form/query-form';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: '/artifacts', pathMatch: 'full' },
|
||||
{ path: 'artifacts', component: ArtifactsListComponent },
|
||||
{ path: 'upload', component: UploadFormComponent },
|
||||
{ path: 'query', component: QueryFormComponent }
|
||||
];
|
||||
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><span class="logo">[W13]</span></h1>
|
||||
<div class="header-info">
|
||||
<span class="badge">{{ deploymentMode }}</span>
|
||||
<span class="badge">{{ storageBackend }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<a routerLink="/artifacts" routerLinkActive="active" class="tab-button">
|
||||
<span class="material-icons md-16">storage</span> Artifacts
|
||||
</a>
|
||||
<a routerLink="/upload" routerLinkActive="active" class="tab-button">
|
||||
<span class="material-icons md-16">upload</span> Upload
|
||||
</a>
|
||||
<a routerLink="/query" routerLinkActive="active" class="tab-button">
|
||||
<span class="material-icons md-16">search</span> Query
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./app.css']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
deploymentMode: string = '';
|
||||
storageBackend: string = '';
|
||||
|
||||
constructor(private artifactService: ArtifactService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.artifactService.getApiInfo().subscribe({
|
||||
next: (info) => {
|
||||
this.deploymentMode = `Mode: ${info.deployment_mode}`;
|
||||
this.storageBackend = `Storage: ${info.storage_backend}`;
|
||||
},
|
||||
error: (err) => console.error('Failed to load API info:', err)
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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}`, {});
|
||||
}
|
||||
}
|
||||
14
frontend/src/index.html
Normal file
14
frontend/src/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!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/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';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -33,6 +33,21 @@ header {
|
||||
header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
color: #60a5fa;
|
||||
letter-spacing: -1px;
|
||||
padding: 2px 4px;
|
||||
border: 2px solid #60a5fa;
|
||||
border-radius: 4px;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
@@ -524,6 +539,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;
|
||||
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": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
/* 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": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
frontend/tsconfig.spec.json
Normal file
14
frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* 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/**/*.ts"
|
||||
]
|
||||
}
|
||||
69
quickstart-airgap.sh
Executable file
69
quickstart-airgap.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo "Warehouse13 - Air-Gapped Quick Start"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "This script is for restricted/air-gapped environments"
|
||||
echo "where npm packages cannot be downloaded during Docker build."
|
||||
echo ""
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
echo "Error: Must run from project root directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "Error: Docker is not installed. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker Compose is installed
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "Error: Docker Compose is not installed. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Step 1: Starting Docker containers..."
|
||||
echo "==========================================="
|
||||
docker-compose up -d --build
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Waiting for services to be ready..."
|
||||
sleep 15
|
||||
|
||||
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: docker-compose logs -f"
|
||||
echo "To stop: docker-compose down"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Testing the API..."
|
||||
echo "========================================="
|
||||
|
||||
# Wait a bit more for API to be fully ready
|
||||
sleep 5
|
||||
|
||||
# Test health endpoint
|
||||
if curl -s http://localhost:8000/health | grep -q "healthy"; then
|
||||
echo "✓ API is healthy!"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Setup complete! 🚀"
|
||||
echo "========================================="
|
||||
else
|
||||
echo "⚠ API is not responding yet. Please wait a moment and check http://localhost:8000/health"
|
||||
fi
|
||||
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
|
||||
@@ -1,7 +1,7 @@
|
||||
# Test Artifact Data Lake - Quick Start (PowerShell)
|
||||
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host "Obsidian - Quick Start" -ForegroundColor Cyan
|
||||
Write-Host "Warehouse13 - Quick Start" -ForegroundColor Cyan
|
||||
Write-Host "=========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
@@ -121,7 +121,7 @@ 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 "$composeCmd logs -f app" -ForegroundColor Yellow
|
||||
Write-Host " Restart services: " -NoNewline
|
||||
Write-Host "$composeCmd restart" -ForegroundColor Yellow
|
||||
Write-Host " Stop all: " -NoNewline
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo "Obsidian - Quick Start"
|
||||
echo "Warehouse13 - Quick Start"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
@@ -41,7 +41,7 @@ echo "========================================="
|
||||
echo "Services are running!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "API: http://localhost:8000"
|
||||
echo "Web UI: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo "MinIO Console: http://localhost:9001"
|
||||
echo " Username: minioadmin"
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Obsidian - Test Artifact Data Lake</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>◆ Obsidian</h1>
|
||||
<div class="header-info">
|
||||
<span id="deployment-mode" class="badge"></span>
|
||||
<span id="storage-backend" class="badge"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab-button active" onclick="showTab('artifacts')">
|
||||
<i data-lucide="database" style="width: 16px; height: 16px;"></i> Artifacts
|
||||
</button>
|
||||
<button class="tab-button" onclick="showTab('upload')">
|
||||
<i data-lucide="upload" style="width: 16px; height: 16px;"></i> Upload
|
||||
</button>
|
||||
<button class="tab-button" onclick="showTab('query')">
|
||||
<i data-lucide="search" style="width: 16px; height: 16px;"></i> Query
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Artifacts Tab -->
|
||||
<div id="artifacts-tab" class="tab-content active">
|
||||
<div class="toolbar">
|
||||
<button onclick="loadArtifacts()" class="btn btn-primary">
|
||||
<i data-lucide="refresh-cw" style="width: 16px; height: 16px;"></i> Refresh
|
||||
</button>
|
||||
<button id="auto-refresh-toggle" onclick="toggleAutoRefresh()" class="btn btn-success">
|
||||
Auto-refresh: ON
|
||||
</button>
|
||||
<button onclick="generateSeedData()" class="btn btn-secondary">
|
||||
<i data-lucide="sparkles" style="width: 16px; height: 16px;"></i> Generate Seed Data
|
||||
</button>
|
||||
|
||||
<span id="artifact-count" class="count-badge"></span>
|
||||
|
||||
<div class="filter-inline">
|
||||
<i data-lucide="search" style="width: 16px; height: 16px; color: #64748b;"></i>
|
||||
<input type="text" id="filter-search" placeholder="Search..." oninput="filterTable()">
|
||||
<button onclick="clearFilters()" class="btn-clear" title="Clear search">
|
||||
<i data-lucide="x" style="width: 14px; height: 14px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="artifacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortTable('test_suite')">
|
||||
Sim Source <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('filename')">
|
||||
Artifacts <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('created_at')">
|
||||
Date <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('test_name')">
|
||||
Uploaded By <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="artifacts-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">Loading artifacts...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button onclick="previousPage()" id="prev-btn" class="btn">← Previous</button>
|
||||
<span id="page-info">Page 1</span>
|
||||
<button onclick="nextPage()" id="next-btn" class="btn">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Tab -->
|
||||
<div id="upload-tab" class="tab-content">
|
||||
<div class="upload-section">
|
||||
<h2>Upload Artifact</h2>
|
||||
<form id="upload-form" onsubmit="uploadArtifact(event)">
|
||||
<div class="form-group">
|
||||
<label for="file">File *</label>
|
||||
<input type="file" id="file" name="file" required>
|
||||
<small>Supported: CSV, JSON, binary files, PCAP</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sim-source">Sim Source *</label>
|
||||
<input type="text" id="sim-source" name="test_suite" placeholder="e.g., Jenkins, GitLab CI" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uploaded-by">Uploaded By *</label>
|
||||
<input type="text" id="uploaded-by" name="test_name" placeholder="e.g., john.doe" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sim-source-id">SIM Source ID (for grouping)</label>
|
||||
<input type="text" id="sim-source-id" name="sim_source_id" placeholder="e.g., sim_run_20251015_001">
|
||||
<small>Use same ID for multiple artifacts from same source</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (comma-separated) *</label>
|
||||
<input type="text" id="tags" name="tags" placeholder="e.g., regression, smoke, critical" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="test-result">Test Result</label>
|
||||
<select id="test-result" name="test_result">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="pass">Pass</option>
|
||||
<option value="fail">Fail</option>
|
||||
<option value="skip">Skip</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="version">Version</label>
|
||||
<input type="text" id="version" name="version" placeholder="e.g., v1.0.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="Describe this artifact..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="test-config">Test Config (JSON)</label>
|
||||
<textarea id="test-config" name="test_config" rows="4" placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="custom-metadata">Custom Metadata (JSON)</label>
|
||||
<textarea id="custom-metadata" name="custom_metadata" rows="4" placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large">
|
||||
<i data-lucide="upload" style="width: 18px; height: 18px;"></i> Upload Artifact
|
||||
</button>
|
||||
</form>
|
||||
<div id="upload-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Query Tab -->
|
||||
<div id="query-tab" class="tab-content">
|
||||
<div class="query-section">
|
||||
<h2>Query Artifacts</h2>
|
||||
<form id="query-form" onsubmit="queryArtifacts(event)">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-filename">Filename</label>
|
||||
<input type="text" id="q-filename" placeholder="Search filename...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-type">File Type</label>
|
||||
<select id="q-type">
|
||||
<option value="">All</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="binary">Binary</option>
|
||||
<option value="pcap">PCAP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-test-name">Test Name</label>
|
||||
<input type="text" id="q-test-name" placeholder="Search test name...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-suite">Test Suite</label>
|
||||
<input type="text" id="q-suite" placeholder="e.g., integration">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-result">Test Result</label>
|
||||
<select id="q-result">
|
||||
<option value="">All</option>
|
||||
<option value="pass">Pass</option>
|
||||
<option value="fail">Fail</option>
|
||||
<option value="skip">Skip</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-tags">Tags (comma-separated)</label>
|
||||
<input type="text" id="q-tags" placeholder="e.g., regression, smoke">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-start-date">Start Date</label>
|
||||
<input type="datetime-local" id="q-start-date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-end-date">End Date</label>
|
||||
<input type="datetime-local" id="q-end-date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large">
|
||||
<i data-lucide="search" style="width: 18px; height: 18px;"></i> Search
|
||||
</button>
|
||||
<button type="button" onclick="clearQuery()" class="btn btn-secondary">
|
||||
<i data-lucide="x" style="width: 18px; height: 18px;"></i> Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artifact Detail Modal -->
|
||||
<div id="detail-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeDetailModal()">×</span>
|
||||
<h2>Artifact Details</h2>
|
||||
<div id="detail-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
// Initialize Lucide icons
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
592
static/js/app.js
592
static/js/app.js
@@ -1,592 +0,0 @@
|
||||
// API Base URL
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Pagination
|
||||
let currentPage = 1;
|
||||
let pageSize = 25;
|
||||
let totalArtifacts = 0;
|
||||
|
||||
// Auto-refresh
|
||||
let autoRefreshEnabled = true;
|
||||
let autoRefreshInterval = null;
|
||||
const REFRESH_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
// Sorting and filtering
|
||||
let allArtifacts = []; // Store all artifacts for client-side sorting/filtering
|
||||
let currentSortColumn = null;
|
||||
let currentSortDirection = 'asc';
|
||||
|
||||
// Load API info on page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
loadApiInfo();
|
||||
loadArtifacts();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
// Load API information
|
||||
async function loadApiInfo() {
|
||||
try {
|
||||
const response = await fetch('/api');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('deployment-mode').textContent = `Mode: ${data.deployment_mode}`;
|
||||
document.getElementById('storage-backend').textContent = `Storage: ${data.storage_backend}`;
|
||||
} catch (error) {
|
||||
console.error('Error loading API info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load artifacts
|
||||
async function loadArtifacts(limit = pageSize, offset = 0) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/?limit=${limit}&offset=${offset}`);
|
||||
const artifacts = await response.json();
|
||||
|
||||
allArtifacts = artifacts; // Store for sorting/filtering
|
||||
displayArtifacts(artifacts);
|
||||
updatePagination(artifacts.length);
|
||||
} catch (error) {
|
||||
console.error('Error loading artifacts:', error);
|
||||
document.getElementById('artifacts-tbody').innerHTML = `
|
||||
<tr><td colspan="5" class="loading" style="color: #ef4444;">
|
||||
Error loading artifacts: ${error.message}
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display artifacts in table
|
||||
function displayArtifacts(artifacts) {
|
||||
const tbody = document.getElementById('artifacts-tbody');
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading">No artifacts found. Upload some files to get started!</td></tr>';
|
||||
document.getElementById('artifact-count').textContent = '0 artifacts';
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply current sort if active
|
||||
let displayedArtifacts = artifacts;
|
||||
if (currentSortColumn) {
|
||||
displayedArtifacts = applySorting([...artifacts]);
|
||||
}
|
||||
|
||||
tbody.innerHTML = displayedArtifacts.map(artifact => `
|
||||
<tr>
|
||||
<td>${artifact.sim_source_id || artifact.test_suite || '-'}</td>
|
||||
<td>
|
||||
<a href="#" onclick="showDetail(${artifact.id}); return false;" style="color: #60a5fa; text-decoration: none;">
|
||||
${escapeHtml(artifact.filename)}
|
||||
</a>
|
||||
${artifact.tags && artifact.tags.length > 0 ? `<br><div style="margin-top: 5px;">${formatTags(artifact.tags)}</div>` : ''}
|
||||
</td>
|
||||
<td>${formatDate(artifact.created_at)}</td>
|
||||
<td>${artifact.test_name || '-'}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="icon-btn" onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" title="Download">
|
||||
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="deleteArtifact(${artifact.id})" title="Delete">
|
||||
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('artifact-count').textContent = `${displayedArtifacts.length} artifacts`;
|
||||
|
||||
// Re-initialize Lucide icons for dynamically added content
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Format result badge
|
||||
function formatResult(result) {
|
||||
if (!result) return '-';
|
||||
const className = `result-badge result-${result}`;
|
||||
return `<span class="${className}">${result}</span>`;
|
||||
}
|
||||
|
||||
// Format tags
|
||||
function formatTags(tags) {
|
||||
if (!tags || tags.length === 0) return '-';
|
||||
return tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join(' ');
|
||||
}
|
||||
|
||||
// Format bytes
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Show artifact detail
|
||||
async function showDetail(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/${id}`);
|
||||
const artifact = await response.json();
|
||||
|
||||
const detailContent = document.getElementById('detail-content');
|
||||
detailContent.innerHTML = `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">ID</div>
|
||||
<div class="detail-value">${artifact.id}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Filename</div>
|
||||
<div class="detail-value">${escapeHtml(artifact.filename)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">File Type</div>
|
||||
<div class="detail-value"><span class="file-type-badge">${artifact.file_type}</span></div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Size</div>
|
||||
<div class="detail-value">${formatBytes(artifact.file_size)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Storage Path</div>
|
||||
<div class="detail-value"><code>${artifact.storage_path}</code></div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Uploaded By</div>
|
||||
<div class="detail-value">${artifact.test_name || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Sim Source</div>
|
||||
<div class="detail-value">${artifact.test_suite || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Test Result</div>
|
||||
<div class="detail-value">${formatResult(artifact.test_result)}</div>
|
||||
</div>
|
||||
${artifact.test_config ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Test Config</div>
|
||||
<div class="detail-value"><pre>${JSON.stringify(artifact.test_config, null, 2)}</pre></div>
|
||||
</div>
|
||||
` : ''}
|
||||
${artifact.custom_metadata ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Custom Metadata</div>
|
||||
<div class="detail-value"><pre>${JSON.stringify(artifact.custom_metadata, null, 2)}</pre></div>
|
||||
</div>
|
||||
` : ''}
|
||||
${artifact.description ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Description</div>
|
||||
<div class="detail-value">${escapeHtml(artifact.description)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${artifact.tags && artifact.tags.length > 0 ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Tags</div>
|
||||
<div class="detail-value">${formatTags(artifact.tags)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Version</div>
|
||||
<div class="detail-value">${artifact.version || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Created</div>
|
||||
<div class="detail-value">${formatDate(artifact.created_at)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Updated</div>
|
||||
<div class="detail-value">${formatDate(artifact.updated_at)}</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 10px;">
|
||||
<button onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" class="btn btn-primary">
|
||||
<i data-lucide="download" style="width: 16px; height: 16px;"></i> Download
|
||||
</button>
|
||||
<button onclick="deleteArtifact(${artifact.id}); closeDetailModal();" class="btn btn-danger">
|
||||
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('detail-modal').classList.add('active');
|
||||
|
||||
// Re-initialize Lucide icons for modal content
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
alert('Error loading artifact details: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close detail modal
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detail-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Download artifact
|
||||
async function downloadArtifact(id, filename) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/${id}/download`);
|
||||
const blob = await response.blob();
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
alert('Error downloading artifact: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete artifact
|
||||
async function deleteArtifact(id) {
|
||||
if (!confirm('Are you sure you want to delete this artifact? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadArtifacts((currentPage - 1) * pageSize, pageSize);
|
||||
alert('Artifact deleted successfully');
|
||||
} else {
|
||||
throw new Error('Failed to delete artifact');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error deleting artifact: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload artifact
|
||||
async function uploadArtifact(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData();
|
||||
|
||||
// Add file
|
||||
const fileInput = document.getElementById('file');
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
// Add optional fields
|
||||
const fields = ['test_name', 'test_suite', 'test_result', 'version', 'description', 'sim_source_id'];
|
||||
fields.forEach(field => {
|
||||
const value = form.elements[field]?.value;
|
||||
if (value) formData.append(field, value);
|
||||
});
|
||||
|
||||
// Add tags (convert comma-separated to JSON array)
|
||||
const tags = document.getElementById('tags').value;
|
||||
if (tags) {
|
||||
const tagsArray = tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
formData.append('tags', JSON.stringify(tagsArray));
|
||||
}
|
||||
|
||||
// Add JSON fields
|
||||
const testConfig = document.getElementById('test-config').value;
|
||||
if (testConfig) {
|
||||
try {
|
||||
JSON.parse(testConfig); // Validate
|
||||
formData.append('test_config', testConfig);
|
||||
} catch (e) {
|
||||
showUploadStatus('Invalid Test Config JSON', false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const customMetadata = document.getElementById('custom-metadata').value;
|
||||
if (customMetadata) {
|
||||
try {
|
||||
JSON.parse(customMetadata); // Validate
|
||||
formData.append('custom_metadata', customMetadata);
|
||||
} catch (e) {
|
||||
showUploadStatus('Invalid Custom Metadata JSON', false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const artifact = await response.json();
|
||||
showUploadStatus(`Successfully uploaded: ${artifact.filename}`, true);
|
||||
form.reset();
|
||||
loadArtifacts();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
showUploadStatus('Upload failed: ' + error.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Show upload status
|
||||
function showUploadStatus(message, success) {
|
||||
const status = document.getElementById('upload-status');
|
||||
status.textContent = message;
|
||||
status.className = success ? 'success' : 'error';
|
||||
|
||||
setTimeout(() => {
|
||||
status.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Query artifacts
|
||||
async function queryArtifacts(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const query = {};
|
||||
|
||||
const filename = document.getElementById('q-filename').value;
|
||||
if (filename) query.filename = filename;
|
||||
|
||||
const fileType = document.getElementById('q-type').value;
|
||||
if (fileType) query.file_type = fileType;
|
||||
|
||||
const testName = document.getElementById('q-test-name').value;
|
||||
if (testName) query.test_name = testName;
|
||||
|
||||
const suite = document.getElementById('q-suite').value;
|
||||
if (suite) query.test_suite = suite;
|
||||
|
||||
const result = document.getElementById('q-result').value;
|
||||
if (result) query.test_result = result;
|
||||
|
||||
const tags = document.getElementById('q-tags').value;
|
||||
if (tags) {
|
||||
query.tags = tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
|
||||
const startDate = document.getElementById('q-start-date').value;
|
||||
if (startDate) query.start_date = new Date(startDate).toISOString();
|
||||
|
||||
const endDate = document.getElementById('q-end-date').value;
|
||||
if (endDate) query.end_date = new Date(endDate).toISOString();
|
||||
|
||||
query.limit = 100;
|
||||
query.offset = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(query)
|
||||
});
|
||||
|
||||
const artifacts = await response.json();
|
||||
|
||||
// Switch to artifacts tab and display results
|
||||
showTab('artifacts');
|
||||
displayArtifacts(artifacts);
|
||||
} catch (error) {
|
||||
alert('Query failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear query form
|
||||
function clearQuery() {
|
||||
document.getElementById('query-form').reset();
|
||||
}
|
||||
|
||||
// Generate seed data
|
||||
async function generateSeedData() {
|
||||
const count = prompt('How many artifacts to generate? (1-100)', '10');
|
||||
if (!count) return;
|
||||
|
||||
const num = parseInt(count);
|
||||
if (isNaN(num) || num < 1 || num > 100) {
|
||||
alert('Please enter a number between 1 and 100');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/seed/generate/${num}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert(result.message);
|
||||
loadArtifacts();
|
||||
} else {
|
||||
throw new Error(result.detail || 'Generation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error generating seed data: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Tab navigation
|
||||
function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
function updatePagination(count) {
|
||||
const pageInfo = document.getElementById('page-info');
|
||||
pageInfo.textContent = `Page ${currentPage}`;
|
||||
|
||||
document.getElementById('prev-btn').disabled = currentPage === 1;
|
||||
document.getElementById('next-btn').disabled = count < pageSize;
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
currentPage++;
|
||||
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
|
||||
}
|
||||
|
||||
// Auto-refresh functions
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
}
|
||||
|
||||
if (autoRefreshEnabled) {
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
// Only refresh if on the artifacts tab
|
||||
const artifactsTab = document.getElementById('artifacts-tab');
|
||||
if (artifactsTab && artifactsTab.classList.contains('active')) {
|
||||
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshEnabled = !autoRefreshEnabled;
|
||||
|
||||
const toggleBtn = document.getElementById('auto-refresh-toggle');
|
||||
if (autoRefreshEnabled) {
|
||||
toggleBtn.textContent = 'Auto-refresh: ON';
|
||||
toggleBtn.classList.remove('btn-secondary');
|
||||
toggleBtn.classList.add('btn-success');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
toggleBtn.textContent = 'Auto-refresh: OFF';
|
||||
toggleBtn.classList.remove('btn-success');
|
||||
toggleBtn.classList.add('btn-secondary');
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sorting to artifacts array
|
||||
function applySorting(artifacts) {
|
||||
if (!currentSortColumn) return artifacts;
|
||||
|
||||
return artifacts.sort((a, b) => {
|
||||
let aVal = a[currentSortColumn] || '';
|
||||
let bVal = b[currentSortColumn] || '';
|
||||
|
||||
// Handle date sorting
|
||||
if (currentSortColumn === 'created_at') {
|
||||
aVal = new Date(aVal).getTime();
|
||||
bVal = new Date(bVal).getTime();
|
||||
} else {
|
||||
// String comparison (case insensitive)
|
||||
aVal = String(aVal).toLowerCase();
|
||||
bVal = String(bVal).toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Sorting functionality
|
||||
function sortTable(column) {
|
||||
// Toggle sort direction if clicking same column
|
||||
if (currentSortColumn === column) {
|
||||
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSortColumn = column;
|
||||
currentSortDirection = 'asc';
|
||||
}
|
||||
|
||||
// Update sort indicators
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
});
|
||||
|
||||
const sortedHeader = event.target.closest('th');
|
||||
sortedHeader.classList.add(`sort-${currentSortDirection}`);
|
||||
|
||||
// Apply filter and sort
|
||||
filterTable();
|
||||
}
|
||||
|
||||
// Filtering functionality - searches across all columns
|
||||
function filterTable() {
|
||||
const searchTerm = document.getElementById('filter-search').value.toLowerCase();
|
||||
|
||||
const filteredArtifacts = allArtifacts.filter(artifact => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
// Search across all relevant fields
|
||||
const searchableText = [
|
||||
artifact.test_suite || '',
|
||||
artifact.filename || '',
|
||||
artifact.test_name || '',
|
||||
formatDate(artifact.created_at)
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return searchableText.includes(searchTerm);
|
||||
});
|
||||
|
||||
displayArtifacts(filteredArtifacts);
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-search').value = '';
|
||||
filterTable();
|
||||
}
|
||||
Reference in New Issue
Block a user