Compare commits
91 Commits
82e8cea256
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019208ad0e | ||
|
|
af4fd324eb | ||
|
|
3d30c84497 | ||
|
|
6f8247e4fd | ||
|
|
014dcb5b09 | ||
|
|
2f786aa14f | ||
|
|
2e9d2ca143 | ||
|
|
4f0e7013e4 | ||
|
|
c1df449f57 | ||
|
|
a07c3ccd9c | ||
| 5fe92cde25 | |||
|
|
a4e4cb4c5f | ||
|
|
ec5d9916ba | ||
| 34ce3ef998 | |||
| d70cbdb12f | |||
| b63597345a | |||
| a910b8270a | |||
| 27afda2d70 | |||
|
|
247d207e3b | ||
|
|
a12df3306d | ||
|
|
29eb7358df | ||
|
|
28daa5c078 | ||
|
|
79f17c423b | ||
|
|
50bcd35e68 | ||
|
|
61cd5c471a | ||
|
|
e83ecf4717 | ||
|
|
53b37c2e16 | ||
|
|
a9bf480954 | ||
|
|
4afbc53420 | ||
|
|
d0594fb161 | ||
|
|
7a0e0c95aa | ||
| 943cd6935b | |||
| 4a270dbfe3 | |||
|
|
e08ab62a32 | ||
|
|
c7ae399615 | ||
| 33d06bc94d | |||
| e2e5c683e4 | |||
|
|
838e145598 | ||
| 15e0f886d7 | |||
|
|
7c50f0a59a | ||
|
|
6508363c12 | ||
| 80242b9602 | |||
| 4641cbb3fa | |||
| 59001222a0 | |||
|
|
5e958ac8c3 | ||
| 7126c618ea | |||
|
|
543617cc08 | ||
|
|
a1151d5e89 | ||
|
|
10b95ec5ef | ||
|
|
bf5e5c7542 | ||
|
|
090361cf66 | ||
|
|
18e70cd445 | ||
| a256e01444 | |||
|
|
5920bf1617 | ||
|
|
122e3f2edc | ||
|
|
2584e92af2 | ||
|
|
1016fee300 | ||
|
|
cda0e99ce7 | ||
|
|
a08b7af8ca | ||
|
|
7305cef18c | ||
|
|
450faad45c | ||
|
|
16d1afc44b | ||
|
|
a607a3f15b | ||
|
|
009c3261c8 | ||
|
|
e7532f0324 | ||
| 7ea16fe48e | |||
| c4d325ecd3 | |||
| 9e3af1fc07 | |||
| bd6c2ce9c1 | |||
| 59defcefe5 | |||
| d9c6f490f0 | |||
| a3a2cec9cf | |||
| 2054181228 | |||
| 6c01329f27 | |||
| c177be326c | |||
| 972bb50c64 | |||
| 20a4ea1655 | |||
| 0856ca5b7a | |||
| 0e5abbbece | |||
| d69c209101 | |||
| 2861022ac6 | |||
| 21347d8c65 | |||
| 6eab60987e | |||
| f910c0d67d | |||
| 17fc9c9b75 | |||
| 85e8776fc3 | |||
| 1a47ba9369 | |||
| ca86e7a04f | |||
| b584cb96bf | |||
| 4d9d235111 | |||
| 4bc8310070 |
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npx:*)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(ng add:*)",
|
|
||||||
"Bash(npm run start:*)",
|
|
||||||
"Bash(chmod:*)",
|
|
||||||
"Bash(pkill:*)",
|
|
||||||
"Bash(./quickstart.sh:*)",
|
|
||||||
"Bash(docker-compose:*)",
|
|
||||||
"Bash(timeout:*)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(quickstart.bat --help)",
|
|
||||||
"Bash(powershell:*)",
|
|
||||||
"Bash(netstat:*)",
|
|
||||||
"Bash(findstr:*)",
|
|
||||||
"Bash(taskkill:*)",
|
|
||||||
"Bash(Get-Process -Name \"node\" -ErrorAction SilentlyContinue)",
|
|
||||||
"Bash(Stop-Process -Force)",
|
|
||||||
"Bash(python -m uvicorn:*)",
|
|
||||||
"Bash(docker compose:*)",
|
|
||||||
"Bash(docker:*)",
|
|
||||||
"Bash(npm start)",
|
|
||||||
"Bash(python:*)",
|
|
||||||
"Bash(ng serve:*)",
|
|
||||||
"Bash(if exist .angular rmdir /s /q .angular)",
|
|
||||||
"Bash(dir:*)",
|
|
||||||
"Bash(tree:*)"
|
|
||||||
],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
# Python cache
|
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
@@ -6,50 +5,15 @@ __pycache__
|
|||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Node modules (critical for fixing esbuild platform issue)
|
|
||||||
node_modules
|
|
||||||
frontend/node_modules
|
|
||||||
*/node_modules
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
frontend/dist
|
|
||||||
frontend/.angular
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
*.env
|
*.env
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
*.md
|
||||||
|
|
||||||
# IDE files
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Configuration
|
|
||||||
helm/
|
helm/
|
||||||
.gitlab-ci.yml
|
.gitlab-ci.yml
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
# Development files
|
|
||||||
frontend/.vscode
|
|
||||||
frontend/src/**/*.spec.ts
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
coverage
|
|
||||||
|
|||||||
@@ -27,3 +27,9 @@ MINIO_SECURE=false
|
|||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
MAX_UPLOAD_SIZE=524288000
|
MAX_UPLOAD_SIZE=524288000
|
||||||
|
|
||||||
|
# NPM Configuration (for frontend build)
|
||||||
|
# Default: https://registry.npmjs.org/ (public npm registry)
|
||||||
|
# For restricted environments, set to your custom npm proxy/registry URL
|
||||||
|
# Example: http://your-nexus-server:8081/repository/npm-proxy/
|
||||||
|
NPM_REGISTRY=https://registry.npmjs.org/
|
||||||
|
|||||||
26
.gitea/workflows/docker.yml
Normal file
26
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.bitstorm.ca # e.g., docker.io for Docker Hub
|
||||||
|
username: ${{ secrets.REGISTRY_LOGIN }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: git.bitstorm.ca/bitforge/warehouse13:latest
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -86,3 +86,10 @@ helm/charts/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
package-lock.json
|
||||||
|
**/package-lock.json
|
||||||
|
|
||||||
|
# Built static files (generated during Docker build from Angular)
|
||||||
|
static/
|
||||||
|
|||||||
182
.gitlab-ci.yml
182
.gitlab-ci.yml
@@ -1,164 +1,42 @@
|
|||||||
stages:
|
stages:
|
||||||
- test
|
|
||||||
- build
|
- build
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
variables:
|
|
||||||
DOCKER_DRIVER: overlay2
|
|
||||||
DOCKER_TLS_CERTDIR: "/certs"
|
|
||||||
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
|
||||||
LATEST_TAG: $CI_REGISTRY_IMAGE:latest
|
|
||||||
|
|
||||||
# Test stage
|
build_container:
|
||||||
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
|
stage: build
|
||||||
image: docker:24
|
image: deps.global.bsf.tools/quay.io/buildah/stable:latest
|
||||||
services:
|
variables:
|
||||||
- docker:24-dind
|
IMAGE_NAME: "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
|
||||||
before_script:
|
before_script:
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
- mkdir -p /tmp/buildah-storage
|
||||||
|
- export BUILDAH_ROOT="/tmp/buildah-storage"
|
||||||
|
- echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
script:
|
script:
|
||||||
- echo "Building Docker image..."
|
# - buildah bud --build-arg NPM_REGISTRY=https://deps.global.bsf.tools/artifactory/api/npm/registry.npmjs.org/ --storage-driver vfs --isolation chroot -t $IMAGE_NAME .
|
||||||
- docker build -t $IMAGE_TAG -t $LATEST_TAG .
|
- buildah pull git.bitstorm.ca/bitforge/warehouse13:latest
|
||||||
- docker push $IMAGE_TAG
|
- buildah tag git.bitstorm.ca/bitforge/warehouse13:latest $IMAGE_NAME
|
||||||
- docker push $LATEST_TAG
|
- echo "Pushing $IMAGE_NAME"
|
||||||
only:
|
- buildah push $IMAGE_NAME
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- develop
|
|
||||||
- tags
|
|
||||||
|
|
||||||
# Deploy to development
|
deploy_helm_charts:
|
||||||
deploy:dev:
|
|
||||||
stage: deploy
|
stage: deploy
|
||||||
image: alpine/helm:latest
|
image:
|
||||||
before_script:
|
name: deps.global.bsf.tools/registry-1.docker.io/alpine/k8s:1.29.12
|
||||||
- apk add --no-cache curl
|
parallel:
|
||||||
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
matrix:
|
||||||
- chmod +x kubectl
|
# - ENV: "prod"
|
||||||
- mv kubectl /usr/local/bin/
|
# VALUES_FILE: "helm/values-prod.yaml"
|
||||||
- mkdir -p ~/.kube
|
# CONTEXT: "esv/bsf/bsf-services/gitlab-kaas-agent-config:services-prod-agent"
|
||||||
- echo "$KUBE_CONFIG_DEV" | base64 -d > ~/.kube/config
|
# NAMESPACE: "bsf-services-namespace"
|
||||||
|
# ONLY: "main"
|
||||||
|
- ENV: "dev"
|
||||||
|
VALUES_FILE: "helm/warehouse13/values.yaml"
|
||||||
|
CONTEXT: "esv/bsf/bsf-services/gitlab-kaas-agent-config:services-prod-agent"
|
||||||
|
NAMESPACE: "bsf-services-dev-namespace"
|
||||||
|
# ONLY: ["branches", "!main"]
|
||||||
script:
|
script:
|
||||||
- echo "Deploying to development environment..."
|
- kubectl config use-context $CONTEXT
|
||||||
|
- echo "Deploy - $CI_REGISTRY_NAME - $CI_COMMIT_REF_SLUG - $CI_COMMIT_SHORT_SHA"
|
||||||
- |
|
- |
|
||||||
helm upgrade --install datalake-dev ./helm \
|
helm upgrade --install warehouse13-dev ./helm/warehouse13 --namespace $NAMESPACE -f $VALUES_FILE --set app.image.repository=$CI_REGISTRY_IMAGE --set app.image.tag=$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA
|
||||||
--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
|
|
||||||
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
|
||||||
45
Dockerfile
45
Dockerfile
@@ -1,12 +1,42 @@
|
|||||||
FROM python:3.11-slim
|
# Multi-stage build: First stage builds Angular frontend
|
||||||
|
FROM node:20.11-alpine3.19 AS frontend-build
|
||||||
|
|
||||||
|
# Accept npm registry as build argument
|
||||||
|
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Configure npm registry if custom registry is provided
|
||||||
|
RUN if [ "$NPM_REGISTRY" != "https://registry.npmjs.org/" ]; then \
|
||||||
|
echo "Using custom npm registry: $NPM_REGISTRY"; \
|
||||||
|
npm config set registry "$NPM_REGISTRY"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies (ignore package-lock.json if using custom registry)
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
RUN npm run build:prod
|
||||||
|
|
||||||
|
# Second stage: Python backend with Angular frontend
|
||||||
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies for Alpine
|
||||||
RUN apt-get update && apt-get install -y \
|
# Alpine uses apk instead of apt-get and is lighter/faster
|
||||||
|
RUN apk add --no-cache \
|
||||||
gcc \
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
postgresql-dev \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
linux-headers
|
||||||
|
|
||||||
# Copy requirements and install Python dependencies
|
# Copy requirements and install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -18,8 +48,11 @@ COPY utils/ ./utils/
|
|||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
|
|
||||||
# Create non-root user
|
# Copy built Angular frontend from first stage to static directory
|
||||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
COPY --from=frontend-build /frontend/dist/frontend/browser ./static/
|
||||||
|
|
||||||
|
# Create non-root user (Alpine uses adduser instead of useradd)
|
||||||
|
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# Multi-stage build for Angular frontend
|
|
||||||
FROM node:18-alpine as frontend-builder
|
|
||||||
|
|
||||||
# Install dependencies for native modules
|
|
||||||
RUN apk add --no-cache python3 make g++
|
|
||||||
|
|
||||||
WORKDIR /frontend
|
|
||||||
|
|
||||||
# Copy package files first for better layer caching
|
|
||||||
COPY frontend/package*.json ./
|
|
||||||
|
|
||||||
# Clean install dependencies with explicit platform targeting
|
|
||||||
# This ensures esbuild and other native modules are built for Alpine Linux
|
|
||||||
RUN npm ci --force
|
|
||||||
|
|
||||||
# Copy frontend source (excluding node_modules via .dockerignore)
|
|
||||||
COPY frontend/src ./src
|
|
||||||
COPY frontend/public ./public
|
|
||||||
COPY frontend/angular.json ./
|
|
||||||
COPY frontend/tsconfig*.json ./
|
|
||||||
|
|
||||||
# Build the Angular app for production
|
|
||||||
RUN npm run build --verbose
|
|
||||||
|
|
||||||
# Production image with nginx
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy built Angular app
|
|
||||||
COPY --from=frontend-builder /frontend/dist/frontend /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy nginx configuration
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Expose port 80
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Simple approach - build on host and copy dist folder
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy pre-built Angular app
|
|
||||||
COPY frontend/dist/frontend /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy nginx configuration
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Expose port 80
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
99
README.md
99
README.md
@@ -1,4 +1,6 @@
|
|||||||
# Test Artifact Data Lake
|
# Warehouse13
|
||||||
|
|
||||||
|
**Enterprise Test Artifact Storage**
|
||||||
|
|
||||||
A lightweight, cloud-native API for storing and querying test artifacts including CSV files, JSON files, binary files, and packet captures (PCAP). Built with FastAPI and supports both AWS S3 and self-hosted MinIO storage backends.
|
A lightweight, cloud-native API for storing and querying test artifacts including CSV files, JSON files, binary files, and packet captures (PCAP). Built with FastAPI and supports both AWS S3 and self-hosted MinIO storage backends.
|
||||||
|
|
||||||
@@ -34,18 +36,32 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### One-Command Setup
|
### Standard Deployment (Internet Access)
|
||||||
|
|
||||||
**Linux/macOS:**
|
**Linux/macOS:**
|
||||||
```bash
|
```bash
|
||||||
./scripts/quickstart.sh
|
./quickstart.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows (PowerShell):**
|
**Windows (PowerShell):**
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\quickstart.ps1
|
.\quickstart.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Air-Gapped/Restricted Environment Deployment
|
||||||
|
|
||||||
|
**For environments with restricted npm access:**
|
||||||
|
```bash
|
||||||
|
./quickstart-airgap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Builds Angular locally (where npm works)
|
||||||
|
2. Packages pre-built files into Docker
|
||||||
|
3. Starts all services
|
||||||
|
|
||||||
|
See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for detailed instructions.
|
||||||
|
|
||||||
### Manual Setup with Docker Compose
|
### Manual Setup with Docker Compose
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
@@ -198,35 +214,54 @@ MINIO_BUCKET_NAME=test-artifacts
|
|||||||
|
|
||||||
### Kubernetes with Helm
|
### Kubernetes with Helm
|
||||||
|
|
||||||
1. Build and push Docker image:
|
**Quick Start:**
|
||||||
```bash
|
```bash
|
||||||
docker build -t your-registry/datalake:latest .
|
helm install warehouse13 ./helm/warehouse13 --namespace warehouse13 --create-namespace
|
||||||
docker push your-registry/datalake:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install with Helm:
|
**Production Deployment:**
|
||||||
```bash
|
```bash
|
||||||
helm install datalake ./helm \
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
--set image.repository=your-registry/datalake \
|
--namespace warehouse13 \
|
||||||
--set image.tag=latest \
|
--create-namespace \
|
||||||
--namespace datalake \
|
--values ./helm/warehouse13/values-production.yaml
|
||||||
--create-namespace
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Access the API:
|
**Air-Gapped Deployment:**
|
||||||
```bash
|
```bash
|
||||||
kubectl port-forward -n datalake svc/datalake 8000:8000
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13 \
|
||||||
|
--create-namespace \
|
||||||
|
--values ./helm/warehouse13/values-airgapped.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Access the Application:**
|
||||||
|
```bash
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-frontend 4200:80
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-api 8000:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helm Documentation
|
||||||
|
|
||||||
|
- **Full Helm Guide:** [HELM-DEPLOYMENT.md](./docs/HELM-DEPLOYMENT.md)
|
||||||
|
- **Chart README:** [helm/warehouse13/README.md](./helm/warehouse13/README.md)
|
||||||
|
- **Quick Start:** [helm/warehouse13/QUICKSTART.md](./helm/warehouse13/QUICKSTART.md)
|
||||||
|
- **Example Configurations:**
|
||||||
|
- Development: [values-dev.yaml](./helm/warehouse13/values-dev.yaml)
|
||||||
|
- Production: [values-production.yaml](./helm/warehouse13/values-production.yaml)
|
||||||
|
- Air-Gapped: [values-airgapped.yaml](./helm/warehouse13/values-airgapped.yaml)
|
||||||
|
|
||||||
### Helm Configuration
|
### Helm Configuration
|
||||||
|
|
||||||
Edit `helm/values.yaml` to customize:
|
All component images are fully configurable in `helm/warehouse13/values.yaml`:
|
||||||
- Replica count
|
- PostgreSQL image and version
|
||||||
- Resource limits
|
- MinIO image and version
|
||||||
- Storage backend (S3 vs MinIO)
|
- API image and version
|
||||||
- Ingress settings
|
- Frontend image and version
|
||||||
- PostgreSQL settings
|
- Resource limits and requests
|
||||||
- Autoscaling
|
- Storage backend configuration
|
||||||
|
- Ingress and TLS settings
|
||||||
|
- Persistence and storage classes
|
||||||
|
|
||||||
### GitLab CI/CD
|
### GitLab CI/CD
|
||||||
|
|
||||||
@@ -301,26 +336,6 @@ alembic upgrade head
|
|||||||
- Verify `MINIO_ENDPOINT` is correct
|
- Verify `MINIO_ENDPOINT` is correct
|
||||||
- Check MinIO credentials
|
- Check MinIO credentials
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Detailed documentation is available in the `docs/` folder:
|
|
||||||
|
|
||||||
- **[Quick Start Guide](docs/QUICKSTART.md)** - Get started in minutes
|
|
||||||
- **[API Documentation](docs/API.md)** - Complete API reference
|
|
||||||
- **[Architecture](docs/ARCHITECTURE.md)** - System design and architecture
|
|
||||||
- **[Features](docs/FEATURES.md)** - Detailed feature descriptions
|
|
||||||
- **[Deployment Guide](docs/DEPLOYMENT.md)** - Production deployment instructions
|
|
||||||
- **[Frontend Setup](docs/FRONTEND_SETUP.md)** - Angular frontend setup
|
|
||||||
- **[Frontend Usage](docs/FRONTEND_USAGE.md)** - Using the web UI
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
Helper scripts are available in the `scripts/` folder:
|
|
||||||
|
|
||||||
- **`quickstart.sh` / `quickstart.ps1`** - Quick start with Docker Compose
|
|
||||||
- **`quickstart-build.sh`** - Quick start with image rebuild
|
|
||||||
- **`dev-start.sh` / `dev-start.ps1`** - Start development environment
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Your License Here]
|
[Your License Here]
|
||||||
|
|||||||
84
alembic/env.py
Normal file
84
alembic/env.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import your models Base
|
||||||
|
from app.models.artifact import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Override sqlalchemy.url from environment variable
|
||||||
|
if os.getenv("DATABASE_URL"):
|
||||||
|
config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL"))
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
0
alembic/versions/.gitkeep
Normal file
0
alembic/versions/.gitkeep
Normal file
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, Query
|
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Dict
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import io
|
import io
|
||||||
@@ -36,6 +36,7 @@ async def upload_artifact(
|
|||||||
test_suite: Optional[str] = Form(None),
|
test_suite: Optional[str] = Form(None),
|
||||||
test_config: Optional[str] = Form(None),
|
test_config: Optional[str] = Form(None),
|
||||||
test_result: Optional[str] = Form(None),
|
test_result: Optional[str] = Form(None),
|
||||||
|
sim_source_id: Optional[str] = Form(None),
|
||||||
custom_metadata: Optional[str] = Form(None),
|
custom_metadata: Optional[str] = Form(None),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
@@ -51,6 +52,7 @@ async def upload_artifact(
|
|||||||
- **test_suite**: Test suite identifier
|
- **test_suite**: Test suite identifier
|
||||||
- **test_config**: JSON string of test configuration
|
- **test_config**: JSON string of test configuration
|
||||||
- **test_result**: Test result (pass, fail, skip, error)
|
- **test_result**: Test result (pass, fail, skip, error)
|
||||||
|
- **sim_source_id**: SIM source ID to group multiple artifacts
|
||||||
- **custom_metadata**: JSON string of additional metadata
|
- **custom_metadata**: JSON string of additional metadata
|
||||||
- **description**: Text description of the artifact
|
- **description**: Text description of the artifact
|
||||||
- **tags**: JSON array of tags (as string)
|
- **tags**: JSON array of tags (as string)
|
||||||
@@ -88,6 +90,7 @@ async def upload_artifact(
|
|||||||
test_suite=test_suite,
|
test_suite=test_suite,
|
||||||
test_config=test_config_dict,
|
test_config=test_config_dict,
|
||||||
test_result=test_result,
|
test_result=test_result,
|
||||||
|
sim_source_id=sim_source_id,
|
||||||
custom_metadata=metadata_dict,
|
custom_metadata=metadata_dict,
|
||||||
description=description,
|
description=description,
|
||||||
tags=tags_list,
|
tags=tags_list,
|
||||||
@@ -194,6 +197,7 @@ async def query_artifacts(query: ArtifactQuery, db: Session = Depends(get_db)):
|
|||||||
- **test_name**: Filter by test name
|
- **test_name**: Filter by test name
|
||||||
- **test_suite**: Filter by test suite
|
- **test_suite**: Filter by test suite
|
||||||
- **test_result**: Filter by test result
|
- **test_result**: Filter by test result
|
||||||
|
- **sim_source_id**: Filter by SIM source ID
|
||||||
- **tags**: Filter by tags (must contain all specified tags)
|
- **tags**: Filter by tags (must contain all specified tags)
|
||||||
- **start_date**: Filter by creation date (from)
|
- **start_date**: Filter by creation date (from)
|
||||||
- **end_date**: Filter by creation date (to)
|
- **end_date**: Filter by creation date (to)
|
||||||
@@ -212,6 +216,8 @@ async def query_artifacts(query: ArtifactQuery, db: Session = Depends(get_db)):
|
|||||||
q = q.filter(Artifact.test_suite == query.test_suite)
|
q = q.filter(Artifact.test_suite == query.test_suite)
|
||||||
if query.test_result:
|
if query.test_result:
|
||||||
q = q.filter(Artifact.test_result == query.test_result)
|
q = q.filter(Artifact.test_result == query.test_result)
|
||||||
|
if query.sim_source_id:
|
||||||
|
q = q.filter(Artifact.sim_source_id == query.sim_source_id)
|
||||||
if query.tags:
|
if query.tags:
|
||||||
for tag in query.tags:
|
for tag in query.tags:
|
||||||
q = q.filter(Artifact.tags.contains([tag]))
|
q = q.filter(Artifact.tags.contains([tag]))
|
||||||
@@ -240,3 +246,20 @@ async def list_artifacts(
|
|||||||
Artifact.created_at.desc()
|
Artifact.created_at.desc()
|
||||||
).offset(offset).limit(limit).all()
|
).offset(offset).limit(limit).all()
|
||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/grouped-by-sim-source", response_model=Dict[str, List[ArtifactResponse]])
|
||||||
|
async def get_artifacts_grouped_by_sim_source(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all artifacts grouped by SIM source ID"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
artifacts = db.query(Artifact).order_by(Artifact.created_at.desc()).all()
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
|
||||||
|
for artifact in artifacts:
|
||||||
|
sim_source = artifact.sim_source_id or "ungrouped"
|
||||||
|
grouped[sim_source].append(artifact)
|
||||||
|
|
||||||
|
return dict(grouped)
|
||||||
|
|||||||
117
app/api/tags.py
117
app/api/tags.py
@@ -1,117 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.tag import Tag
|
|
||||||
from app.schemas.tag import TagCreate, TagUpdate, TagResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=TagResponse, status_code=201)
|
|
||||||
async def create_tag(tag: TagCreate, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Create a new tag
|
|
||||||
|
|
||||||
- **name**: Tag name (unique, required)
|
|
||||||
- **description**: Tag description (optional)
|
|
||||||
- **color**: Hex color code (optional, e.g., #FF5733)
|
|
||||||
"""
|
|
||||||
# Check if tag already exists
|
|
||||||
existing_tag = db.query(Tag).filter(Tag.name == tag.name).first()
|
|
||||||
if existing_tag:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Tag with name '{tag.name}' already exists")
|
|
||||||
|
|
||||||
db_tag = Tag(**tag.model_dump())
|
|
||||||
db.add(db_tag)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_tag)
|
|
||||||
|
|
||||||
return db_tag
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TagResponse])
|
|
||||||
async def list_tags(
|
|
||||||
limit: int = Query(default=100, le=1000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""List all tags with pagination"""
|
|
||||||
tags = db.query(Tag).order_by(Tag.name).offset(offset).limit(limit).all()
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tag_id}", response_model=TagResponse)
|
|
||||||
async def get_tag(tag_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""Get tag by ID"""
|
|
||||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
|
||||||
if not tag:
|
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
|
||||||
return tag
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/name/{tag_name}", response_model=TagResponse)
|
|
||||||
async def get_tag_by_name(tag_name: str, db: Session = Depends(get_db)):
|
|
||||||
"""Get tag by name"""
|
|
||||||
tag = db.query(Tag).filter(Tag.name == tag_name).first()
|
|
||||||
if not tag:
|
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
|
||||||
return tag
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{tag_id}", response_model=TagResponse)
|
|
||||||
async def update_tag(tag_id: int, tag_update: TagUpdate, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Update a tag
|
|
||||||
|
|
||||||
- **name**: Tag name (optional)
|
|
||||||
- **description**: Tag description (optional)
|
|
||||||
- **color**: Hex color code (optional)
|
|
||||||
"""
|
|
||||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
|
||||||
if not tag:
|
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
|
||||||
|
|
||||||
# Check if new name conflicts with existing tag
|
|
||||||
if tag_update.name and tag_update.name != tag.name:
|
|
||||||
existing_tag = db.query(Tag).filter(Tag.name == tag_update.name).first()
|
|
||||||
if existing_tag:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Tag with name '{tag_update.name}' already exists")
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
update_data = tag_update.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(tag, field, value)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(tag)
|
|
||||||
|
|
||||||
return tag
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}")
|
|
||||||
async def delete_tag(tag_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""Delete a tag"""
|
|
||||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
|
||||||
if not tag:
|
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
|
||||||
|
|
||||||
db.delete(tag)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"message": f"Tag '{tag.name}' deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=List[TagResponse])
|
|
||||||
async def search_tags(
|
|
||||||
query: str = Query(..., min_length=1, description="Search query"),
|
|
||||||
limit: int = Query(default=100, le=1000),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Search tags by name or description"""
|
|
||||||
tags = db.query(Tag).filter(
|
|
||||||
(Tag.name.ilike(f"%{query}%")) | (Tag.description.ilike(f"%{query}%"))
|
|
||||||
).order_by(Tag.name).limit(limit).all()
|
|
||||||
|
|
||||||
return tags
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.artifact import Base as ArtifactBase
|
from app.models.artifact import Base
|
||||||
from app.models.tag import Base as TagBase
|
|
||||||
|
|
||||||
engine = create_engine(settings.database_url)
|
engine = create_engine(settings.database_url)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
@@ -10,8 +9,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Initialize database tables"""
|
"""Initialize database tables"""
|
||||||
ArtifactBase.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
TagBase.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
|||||||
49
app/main.py
49
app/main.py
@@ -1,8 +1,9 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from app.api.artifacts import router as artifacts_router
|
from app.api.artifacts import router as artifacts_router
|
||||||
from app.api.seed import router as seed_router
|
from app.api.seed import router as seed_router
|
||||||
from app.api.tags import router as tags_router
|
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
import logging
|
import logging
|
||||||
@@ -18,8 +19,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Create FastAPI app
|
# Create FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Test Artifact Data Lake",
|
title="Warehouse13",
|
||||||
description="API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
|
description="Enterprise Test Artifact Storage - API for storing and querying test artifacts including CSV, JSON, binary files, and packet captures",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc"
|
redoc_url="/redoc"
|
||||||
@@ -37,9 +38,9 @@ app.add_middleware(
|
|||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(artifacts_router)
|
app.include_router(artifacts_router)
|
||||||
app.include_router(seed_router)
|
app.include_router(seed_router)
|
||||||
app.include_router(tags_router)
|
|
||||||
|
|
||||||
# Note: Frontend is now served separately as an Angular application
|
# Static directory setup
|
||||||
|
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@@ -56,7 +57,7 @@ async def startup_event():
|
|||||||
async def api_root():
|
async def api_root():
|
||||||
"""API root endpoint"""
|
"""API root endpoint"""
|
||||||
return {
|
return {
|
||||||
"message": "Test Artifact Data Lake API",
|
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"deployment_mode": settings.deployment_mode,
|
"deployment_mode": settings.deployment_mode,
|
||||||
@@ -66,12 +67,40 @@ async def api_root():
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def ui_root():
|
async def ui_root():
|
||||||
"""API root - Frontend is served separately"""
|
"""Serve the UI"""
|
||||||
|
index_path = os.path.join(static_dir, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return FileResponse(index_path)
|
||||||
|
else:
|
||||||
return {
|
return {
|
||||||
"message": "Test Artifact Data Lake API",
|
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"frontend": "Frontend is served separately on port 4200 (development) or via reverse proxy (production)",
|
"ui": "UI not found. Serving API only.",
|
||||||
|
"deployment_mode": settings.deployment_mode,
|
||||||
|
"storage_backend": settings.storage_backend
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Catch-all route for Angular SPA routing - must be last
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
"""Serve Angular SPA static files and handle client-side routing"""
|
||||||
|
# Try to serve static file first (JS, CSS, images, etc.)
|
||||||
|
file_path = os.path.join(static_dir, full_path)
|
||||||
|
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
# For all other routes (Angular client-side routes), serve index.html
|
||||||
|
index_path = os.path.join(static_dir, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return FileResponse(index_path)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
"ui": "UI not found. Serving API only.",
|
||||||
"deployment_mode": settings.deployment_mode,
|
"deployment_mode": settings.deployment_mode,
|
||||||
"storage_backend": settings.storage_backend
|
"storage_backend": settings.storage_backend
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class Artifact(Base):
|
|||||||
test_config = Column(JSON)
|
test_config = Column(JSON)
|
||||||
test_result = Column(String(50), index=True) # pass, fail, skip, error
|
test_result = Column(String(50), index=True) # pass, fail, skip, error
|
||||||
|
|
||||||
|
# SIM source grouping - allows multiple artifacts per source
|
||||||
|
sim_source_id = Column(String(100), index=True) # Groups artifacts from same SIM source
|
||||||
|
|
||||||
# Additional metadata
|
# Additional metadata
|
||||||
custom_metadata = Column(JSON)
|
custom_metadata = Column(JSON)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from sqlalchemy import Column, String, Integer, DateTime, Text
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base):
|
|
||||||
__tablename__ = "tags"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
|
||||||
description = Column(Text)
|
|
||||||
color = Column(String(7)) # Hex color code, e.g., #FF5733
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Tag(id={self.id}, name='{self.name}')>"
|
|
||||||
@@ -8,6 +8,7 @@ class ArtifactCreate(BaseModel):
|
|||||||
test_suite: Optional[str] = None
|
test_suite: Optional[str] = None
|
||||||
test_config: Optional[Dict[str, Any]] = None
|
test_config: Optional[Dict[str, Any]] = None
|
||||||
test_result: Optional[str] = None
|
test_result: Optional[str] = None
|
||||||
|
sim_source_id: Optional[str] = None # Groups artifacts from same SIM source
|
||||||
custom_metadata: Optional[Dict[str, Any]] = None
|
custom_metadata: Optional[Dict[str, Any]] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
@@ -26,6 +27,7 @@ class ArtifactResponse(BaseModel):
|
|||||||
test_suite: Optional[str] = None
|
test_suite: Optional[str] = None
|
||||||
test_config: Optional[Dict[str, Any]] = None
|
test_config: Optional[Dict[str, Any]] = None
|
||||||
test_result: Optional[str] = None
|
test_result: Optional[str] = None
|
||||||
|
sim_source_id: Optional[str] = None
|
||||||
custom_metadata: Optional[Dict[str, Any]] = None
|
custom_metadata: Optional[Dict[str, Any]] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
@@ -44,6 +46,7 @@ class ArtifactQuery(BaseModel):
|
|||||||
test_name: Optional[str] = None
|
test_name: Optional[str] = None
|
||||||
test_suite: Optional[str] = None
|
test_suite: Optional[str] = None
|
||||||
test_result: Optional[str] = None
|
test_result: Optional[str] = None
|
||||||
|
sim_source_id: Optional[str] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
start_date: Optional[datetime] = None
|
start_date: Optional[datetime] = None
|
||||||
end_date: Optional[datetime] = None
|
end_date: Optional[datetime] = None
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class TagBase(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=100, description="Tag name")
|
|
||||||
description: Optional[str] = Field(None, description="Tag description")
|
|
||||||
color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$", description="Hex color code (e.g., #FF5733)")
|
|
||||||
|
|
||||||
|
|
||||||
class TagCreate(TagBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(BaseModel):
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Tag name")
|
|
||||||
description: Optional[str] = Field(None, description="Tag description")
|
|
||||||
color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$", description="Hex color code")
|
|
||||||
|
|
||||||
|
|
||||||
class TagResponse(TagBase):
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: user
|
|
||||||
POSTGRES_PASSWORD: password
|
|
||||||
POSTGRES_DB: datalake
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U user"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: minioadmin
|
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9001:9001"
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
api:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://user:password@postgres:5432/datalake
|
|
||||||
STORAGE_BACKEND: minio
|
|
||||||
MINIO_ENDPOINT: minio:9000
|
|
||||||
MINIO_ACCESS_KEY: minioadmin
|
|
||||||
MINIO_SECRET_KEY: minioadmin
|
|
||||||
MINIO_BUCKET_NAME: test-artifacts
|
|
||||||
MINIO_SECURE: "false"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.frontend
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
|
||||||
api:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:80"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
minio_data:
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
name: warehouse13
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: password
|
||||||
@@ -34,8 +36,12 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
api:
|
app:
|
||||||
build: .
|
container_name: warehouse13-app
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
@@ -57,9 +63,6 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# Frontend service removed from default compose - use dev-start.sh for development
|
|
||||||
# For production with built frontend, use: docker-compose -f docker-compose.production.yml up
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -1,465 +1,140 @@
|
|||||||
# Deployment Guide
|
# Deployment Options
|
||||||
|
|
||||||
This guide covers deploying the Test Artifact Data Lake in various environments.
|
This project supports two deployment strategies for the Angular frontend, depending on your environment's network access.
|
||||||
|
|
||||||
## Table of Contents
|
## Option 1: Standard Build (Internet Access Required)
|
||||||
- [Local Development](#local-development)
|
|
||||||
- [Docker Compose](#docker-compose)
|
Use the standard `Dockerfile.frontend` which builds the Angular app inside Docker.
|
||||||
- [Kubernetes/Helm](#kuberneteshelm)
|
|
||||||
- [AWS Deployment](#aws-deployment)
|
**Requirements:**
|
||||||
- [Self-Hosted Deployment](#self-hosted-deployment)
|
- Internet access to npm registry
|
||||||
- [GitLab CI/CD](#gitlab-cicd)
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Local Development
|
## Option 2: Pre-built Deployment (Air-Gapped/Restricted Environments) ⭐ RECOMMENDED
|
||||||
|
|
||||||
### Prerequisites
|
Use `Dockerfile.frontend.prebuilt` for environments with restricted npm access.
|
||||||
- Python 3.11+
|
|
||||||
- PostgreSQL 15+
|
|
||||||
- MinIO or AWS S3 access
|
|
||||||
|
|
||||||
### Steps
|
**Requirements:**
|
||||||
|
- Node.js 18+ installed locally (on a machine with npm access)
|
||||||
|
- npm installed locally
|
||||||
|
- No internet required during Docker build
|
||||||
|
|
||||||
1. **Create virtual environment:**
|
**Note:** This project uses Angular 17 with webpack bundler (not Vite) for better compatibility with restricted npm environments.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
### Quick Start (Recommended)
|
||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
./quickstart-airgap.sh
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies:**
|
This script will:
|
||||||
```bash
|
1. Build the Angular app locally
|
||||||
pip install -r requirements.txt
|
2. Start all Docker containers
|
||||||
```
|
3. Verify the deployment
|
||||||
|
|
||||||
3. **Set up PostgreSQL:**
|
### Manual Steps
|
||||||
```bash
|
|
||||||
createdb datalake
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configure environment:**
|
### Step 1: Build Angular app locally
|
||||||
```bash
|
**IMPORTANT:** You MUST run this step BEFORE `docker-compose up`!
|
||||||
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
|
```bash
|
||||||
helm upgrade datalake ./helm \
|
# Option A: Use the helper script
|
||||||
--namespace datalake \
|
./scripts/build-for-airgap.sh
|
||||||
--set image.tag=1.1.0
|
|
||||||
|
# Option B: Build manually
|
||||||
|
cd frontend
|
||||||
|
npm install # Only needed once or when dependencies change
|
||||||
|
npm run build:prod
|
||||||
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
### Uninstall
|
This creates `frontend/dist/frontend/browser/` which Docker will copy.
|
||||||
|
|
||||||
```bash
|
### Step 2: Update docker-compose.yml
|
||||||
helm uninstall datalake --namespace datalake
|
Edit `docker-compose.yml` and change the frontend dockerfile:
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AWS Deployment
|
|
||||||
|
|
||||||
### Using AWS S3 Storage
|
|
||||||
|
|
||||||
1. **Create S3 bucket:**
|
|
||||||
```bash
|
|
||||||
aws s3 mb s3://your-test-artifacts-bucket
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create IAM user with S3 access:**
|
|
||||||
```bash
|
|
||||||
aws iam create-user --user-name datalake-service
|
|
||||||
aws iam attach-user-policy --user-name datalake-service \
|
|
||||||
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Generate access keys:**
|
|
||||||
```bash
|
|
||||||
aws iam create-access-key --user-name datalake-service
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Deploy with Helm:**
|
|
||||||
```bash
|
|
||||||
helm install datalake ./helm \
|
|
||||||
--namespace datalake \
|
|
||||||
--create-namespace \
|
|
||||||
--set config.storageBackend=s3 \
|
|
||||||
--set aws.enabled=true \
|
|
||||||
--set aws.accessKeyId=YOUR_ACCESS_KEY \
|
|
||||||
--set aws.secretAccessKey=YOUR_SECRET_KEY \
|
|
||||||
--set aws.region=us-east-1 \
|
|
||||||
--set aws.bucketName=your-test-artifacts-bucket \
|
|
||||||
--set minio.enabled=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using EKS
|
|
||||||
|
|
||||||
1. **Create EKS cluster:**
|
|
||||||
```bash
|
|
||||||
eksctl create cluster \
|
|
||||||
--name datalake-cluster \
|
|
||||||
--region us-east-1 \
|
|
||||||
--nodegroup-name standard-workers \
|
|
||||||
--node-type t3.medium \
|
|
||||||
--nodes 3
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure kubectl:**
|
|
||||||
```bash
|
|
||||||
aws eks update-kubeconfig --name datalake-cluster --region us-east-1
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Deploy application:**
|
|
||||||
```bash
|
|
||||||
helm install datalake ./helm \
|
|
||||||
--namespace datalake \
|
|
||||||
--create-namespace \
|
|
||||||
--set config.storageBackend=s3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using RDS for PostgreSQL
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm install datalake ./helm \
|
|
||||||
--namespace datalake \
|
|
||||||
--create-namespace \
|
|
||||||
--set postgresql.enabled=false \
|
|
||||||
--set config.databaseUrl="postgresql://user:pass@your-rds-endpoint:5432/datalake"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Hosted Deployment
|
|
||||||
|
|
||||||
### Using MinIO
|
|
||||||
|
|
||||||
1. **Deploy MinIO:**
|
|
||||||
```bash
|
|
||||||
helm install minio bitnami/minio \
|
|
||||||
--namespace datalake \
|
|
||||||
--create-namespace \
|
|
||||||
--set auth.rootUser=admin \
|
|
||||||
--set auth.rootPassword=adminpassword \
|
|
||||||
--set persistence.size=100Gi
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Deploy application:**
|
|
||||||
```bash
|
|
||||||
helm install datalake ./helm \
|
|
||||||
--namespace datalake \
|
|
||||||
--set config.storageBackend=minio \
|
|
||||||
--set minio.enabled=false \
|
|
||||||
--set minio.endpoint=minio:9000 \
|
|
||||||
--set minio.accessKey=admin \
|
|
||||||
--set minio.secretKey=adminpassword
|
|
||||||
```
|
|
||||||
|
|
||||||
### On-Premise Kubernetes
|
|
||||||
|
|
||||||
1. **Prepare persistent volumes:**
|
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: v1
|
frontend:
|
||||||
kind: PersistentVolume
|
build:
|
||||||
metadata:
|
context: .
|
||||||
name: datalake-postgres-pv
|
dockerfile: Dockerfile.frontend.prebuilt # <-- Change this line
|
||||||
spec:
|
ports:
|
||||||
capacity:
|
- "4200:80"
|
||||||
storage: 20Gi
|
depends_on:
|
||||||
accessModes:
|
- api
|
||||||
- ReadWriteOnce
|
|
||||||
hostPath:
|
|
||||||
path: /data/postgres
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Deploy with local storage:**
|
### Step 3: Build and deploy
|
||||||
```bash
|
```bash
|
||||||
helm install datalake ./helm \
|
docker-compose up -d --build
|
||||||
--namespace datalake \
|
|
||||||
--create-namespace \
|
|
||||||
--set postgresql.persistence.storageClass=local-storage \
|
|
||||||
--set minio.persistence.storageClass=local-storage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
This uses `Dockerfile.frontend.prebuilt` which:
|
||||||
|
1. Copies pre-built Angular files from `frontend/dist/`
|
||||||
## GitLab CI/CD
|
2. Serves with nginx
|
||||||
|
3. No npm/node required in Docker
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. **Configure GitLab variables:**
|
|
||||||
|
|
||||||
Go to Settings → CI/CD → Variables and add:
|
|
||||||
|
|
||||||
| Variable | Description | Protected | Masked |
|
|
||||||
|----------|-------------|-----------|---------|
|
|
||||||
| `CI_REGISTRY_USER` | Docker registry username | No | No |
|
|
||||||
| `CI_REGISTRY_PASSWORD` | Docker registry password | No | Yes |
|
|
||||||
| `KUBE_CONFIG_DEV` | Base64 kubeconfig for dev | No | Yes |
|
|
||||||
| `KUBE_CONFIG_STAGING` | Base64 kubeconfig for staging | Yes | Yes |
|
|
||||||
| `KUBE_CONFIG_PROD` | Base64 kubeconfig for prod | Yes | Yes |
|
|
||||||
|
|
||||||
2. **Encode kubeconfig:**
|
|
||||||
```bash
|
|
||||||
cat ~/.kube/config | base64 -w 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pipeline Stages
|
|
||||||
|
|
||||||
1. **Test**: Runs on all branches and MRs
|
|
||||||
2. **Build**: Builds Docker image on main/develop/tags
|
|
||||||
3. **Deploy**: Manual deployment to dev/staging/prod
|
|
||||||
|
|
||||||
### Deployment Flow
|
|
||||||
|
|
||||||
**Development:**
|
|
||||||
```bash
|
|
||||||
git push origin develop
|
|
||||||
# Manually trigger deploy:dev job in GitLab
|
|
||||||
```
|
|
||||||
|
|
||||||
**Staging:**
|
|
||||||
```bash
|
|
||||||
git push origin main
|
|
||||||
# Manually trigger deploy:staging job in GitLab
|
|
||||||
```
|
|
||||||
|
|
||||||
**Production:**
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
# Manually trigger deploy:prod job in GitLab
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customizing Pipeline
|
|
||||||
|
|
||||||
Edit `.gitlab-ci.yml` to:
|
|
||||||
- Add more test stages
|
|
||||||
- Change deployment namespaces
|
|
||||||
- Adjust Helm values per environment
|
|
||||||
- Add security scanning
|
|
||||||
- Configure rollback procedures
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Kubernetes
|
|
||||||
kubectl get pods -n datalake
|
|
||||||
kubectl logs -f -n datalake deployment/datalake
|
|
||||||
|
|
||||||
# Direct
|
|
||||||
curl http://localhost:8000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metrics
|
|
||||||
|
|
||||||
Add Prometheus monitoring:
|
|
||||||
```bash
|
|
||||||
helm install datalake ./helm \
|
|
||||||
--set metrics.enabled=true \
|
|
||||||
--set serviceMonitor.enabled=true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backup and Recovery
|
|
||||||
|
|
||||||
### Database Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# PostgreSQL
|
|
||||||
kubectl exec -n datalake deployment/datalake-postgresql -- \
|
|
||||||
pg_dump -U user datalake > backup.sql
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
kubectl exec -i -n datalake deployment/datalake-postgresql -- \
|
|
||||||
psql -U user datalake < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Storage Backup
|
|
||||||
|
|
||||||
**S3:**
|
|
||||||
```bash
|
|
||||||
aws s3 sync s3://your-bucket s3://backup-bucket
|
|
||||||
```
|
|
||||||
|
|
||||||
**MinIO:**
|
|
||||||
```bash
|
|
||||||
mc mirror minio/test-artifacts backup/test-artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Pod Not Starting
|
### Build Tool Package Issues
|
||||||
```bash
|
|
||||||
kubectl describe pod -n datalake <pod-name>
|
If you see errors about missing packages like:
|
||||||
kubectl logs -n datalake <pod-name>
|
```
|
||||||
|
Cannot find package "vite"
|
||||||
|
Cannot find package "esbuild"
|
||||||
|
Cannot find package "rollup"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Connection Issues
|
**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.
|
||||||
```bash
|
|
||||||
kubectl exec -it -n datalake deployment/datalake -- \
|
### Custom NPM Registry
|
||||||
psql $DATABASE_URL
|
|
||||||
```
|
For both options, you can use a custom npm registry:
|
||||||
|
|
||||||
### Storage Issues
|
|
||||||
```bash
|
```bash
|
||||||
# Check MinIO
|
# Set in .env file
|
||||||
kubectl port-forward -n datalake svc/minio 9000:9000
|
NPM_REGISTRY=http://your-npm-proxy:8081/repository/npm-proxy/
|
||||||
# Access http://localhost:9000
|
|
||||||
|
# Or inline
|
||||||
|
NPM_REGISTRY=http://your-proxy ./quickstart.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Considerations
|
## Recommendation
|
||||||
|
|
||||||
1. **Use secrets management:**
|
- **Development/Cloud**: Use Option 1 (standard)
|
||||||
- Kubernetes Secrets
|
- **Air-gapped/Enterprise**: Use Option 2 (pre-built) ⭐ **RECOMMENDED**
|
||||||
- AWS Secrets Manager
|
- **CI/CD**: Use Option 2 for faster, more reliable builds
|
||||||
- HashiCorp Vault
|
- **Restricted npm access**: Use Option 2 (pre-built) ⭐ **REQUIRED**
|
||||||
|
|
||||||
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
|
## Build Strategy for Restricted Environments
|
||||||
|
|
||||||
### Database
|
**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.
|
||||||
- Increase connection pool size
|
|
||||||
- Add database indexes
|
|
||||||
- Configure autovacuum
|
|
||||||
|
|
||||||
### API
|
If you encounter any package access errors during builds:
|
||||||
- Increase replica count
|
- `Cannot find package "vite"`
|
||||||
- Configure horizontal pod autoscaling
|
- `Cannot find package "rollup"`
|
||||||
- Adjust resource requests/limits
|
- `Cannot find package "esbuild"`
|
||||||
|
- Any platform-specific binary errors
|
||||||
|
|
||||||
### Storage
|
**Solution:** Use Option 2 (Pre-built) deployment. This completely avoids npm installation in Docker and eliminates all build tool dependency issues.
|
||||||
- Use CDN for frequently accessed files
|
|
||||||
- Configure S3 Transfer Acceleration
|
|
||||||
- Optimize MinIO deployment
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# Frontend Usage Guide
|
|
||||||
|
|
||||||
The Test Artifact Data Lake now features a modern Angular frontend with Material Design components. This guide explains how to run the application in different modes.
|
|
||||||
|
|
||||||
## Quick Start Options
|
|
||||||
|
|
||||||
### 1. Development Mode (Recommended for Development)
|
|
||||||
**Hot reload enabled, fastest for development**
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
./dev-start.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```batch
|
|
||||||
dev-start.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
- Backend services: `http://localhost:8000`
|
|
||||||
- Frontend: `http://localhost:4200` (with hot reload)
|
|
||||||
- API Docs: `http://localhost:8000/docs`
|
|
||||||
- MinIO Console: `http://localhost:9001`
|
|
||||||
|
|
||||||
### 2. Production Mode (Complete Docker Stack)
|
|
||||||
**Pre-built frontend served via Nginx**
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
./quickstart-build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows:** (Manual steps)
|
|
||||||
```batch
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
docker-compose -f docker-compose.production.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
- Complete application: `http://localhost:80`
|
|
||||||
- API (proxied): `http://localhost:80/api/`
|
|
||||||
- API Docs: `http://localhost:80/docs`
|
|
||||||
- MinIO Console: `http://localhost:9001`
|
|
||||||
|
|
||||||
### 3. Backend Only Mode
|
|
||||||
**For API-only usage or custom frontend setup**
|
|
||||||
|
|
||||||
**Any platform:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh # Linux/macOS
|
|
||||||
quickstart.bat # Windows
|
|
||||||
quickstart.ps1 # PowerShell
|
|
||||||
```
|
|
||||||
|
|
||||||
- Backend API: `http://localhost:8000`
|
|
||||||
- API Docs: `http://localhost:8000/docs`
|
|
||||||
- MinIO Console: `http://localhost:9001`
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- **Frontend**: Angular 19 with Angular Material Design
|
|
||||||
- **Backend**: FastAPI with PostgreSQL and MinIO
|
|
||||||
- **Development**: Frontend dev server + Backend containers
|
|
||||||
- **Production**: Nginx serving Angular + Backend containers
|
|
||||||
|
|
||||||
### Ports
|
|
||||||
- `80` - Production frontend (Nginx)
|
|
||||||
- `4200` - Development frontend (Angular dev server)
|
|
||||||
- `8000` - Backend API (FastAPI)
|
|
||||||
- `5432` - PostgreSQL database
|
|
||||||
- `9000` - MinIO storage
|
|
||||||
- `9001` - MinIO console
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
1. Use `dev-start.sh` or `dev-start.bat` for daily development
|
|
||||||
2. Frontend changes automatically reload at `http://localhost:4200`
|
|
||||||
3. Backend API available at `http://localhost:8000`
|
|
||||||
4. Use browser dev tools for debugging
|
|
||||||
|
|
||||||
### Production Deployment
|
|
||||||
1. Build frontend: `npm run build` in `frontend/` directory
|
|
||||||
2. Use `docker-compose.production.yml` for complete stack
|
|
||||||
3. Nginx proxies API requests to backend
|
|
||||||
4. Static assets served efficiently by Nginx
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Frontend Build Issues
|
|
||||||
If you encounter esbuild platform errors:
|
|
||||||
1. Delete `frontend/node_modules`
|
|
||||||
2. Run `npm install` in `frontend/` directory
|
|
||||||
3. Try the development mode first: `./dev-start.sh`
|
|
||||||
|
|
||||||
### Port Conflicts
|
|
||||||
- Development: Change Angular port in `angular.json`
|
|
||||||
- Production: Modify `docker-compose.production.yml` ports
|
|
||||||
|
|
||||||
### Docker Issues
|
|
||||||
- Ensure Docker Desktop is running
|
|
||||||
- Try `docker-compose down` and restart
|
|
||||||
- Check logs: `docker-compose logs -f api`
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- Modern Material Design interface
|
|
||||||
- Responsive design for mobile/tablet
|
|
||||||
- File upload with drag-and-drop
|
|
||||||
- Advanced search and filtering
|
|
||||||
- Tag management system
|
|
||||||
- Real-time notifications
|
|
||||||
- Data visualization
|
|
||||||
- Export capabilities
|
|
||||||
517
docs/HELM-DEPLOYMENT.md
Normal file
517
docs/HELM-DEPLOYMENT.md
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
# Warehouse13 - Kubernetes Deployment with Helm
|
||||||
|
|
||||||
|
This guide covers deploying Warehouse13 to Kubernetes using the official Helm chart.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Quick Start](#quick-start)
|
||||||
|
3. [Deployment Scenarios](#deployment-scenarios)
|
||||||
|
4. [Configuration](#configuration)
|
||||||
|
5. [Post-Deployment](#post-deployment)
|
||||||
|
6. [Upgrading](#upgrading)
|
||||||
|
7. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes 1.19+ cluster
|
||||||
|
- Helm 3.0+
|
||||||
|
- kubectl configured to access your cluster
|
||||||
|
- Persistent volume provisioner (for production deployments)
|
||||||
|
|
||||||
|
### Installing Helm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install helm
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
choco install kubernetes-helm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Standard Deployment (Internet Access)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create namespace
|
||||||
|
kubectl create namespace warehouse13
|
||||||
|
|
||||||
|
# Install with default values
|
||||||
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13
|
||||||
|
|
||||||
|
# Wait for pods to be ready
|
||||||
|
kubectl wait --for=condition=ready pod \
|
||||||
|
--all --namespace warehouse13 --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Access the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-frontend 4200:80
|
||||||
|
|
||||||
|
# API
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-api 8000:8000
|
||||||
|
|
||||||
|
# MinIO Console
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9001:9001
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit:
|
||||||
|
- Frontend: http://localhost:4200
|
||||||
|
- API Docs: http://localhost:8000/docs
|
||||||
|
- MinIO Console: http://localhost:9001
|
||||||
|
|
||||||
|
## Deployment Scenarios
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
|
||||||
|
For local testing or CI/CD:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install warehouse13-dev ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13-dev \
|
||||||
|
--create-namespace \
|
||||||
|
--values ./helm/warehouse13/values-dev.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Single replica for all services
|
||||||
|
- emptyDir storage (no persistence)
|
||||||
|
- Minimal resource requests
|
||||||
|
- Always pull latest dev images
|
||||||
|
|
||||||
|
### Production Environment
|
||||||
|
|
||||||
|
For production with ingress and high availability:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, update the values file with your domain and secrets
|
||||||
|
cp ./helm/warehouse13/values-production.yaml ./my-production-values.yaml
|
||||||
|
|
||||||
|
# Edit the file:
|
||||||
|
# - Set postgres.auth.password
|
||||||
|
# - Set minio.auth.rootUser and rootPassword
|
||||||
|
# - Set ingress.hosts[0].host to your domain
|
||||||
|
# - Update storageClass for your environment
|
||||||
|
|
||||||
|
# Install
|
||||||
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13 \
|
||||||
|
--create-namespace \
|
||||||
|
--values ./my-production-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- 3 replicas for API and frontend
|
||||||
|
- Persistent storage with PVCs
|
||||||
|
- Ingress with TLS support
|
||||||
|
- Resource limits and requests
|
||||||
|
- Health checks enabled
|
||||||
|
- Pod anti-affinity for distribution
|
||||||
|
|
||||||
|
### Air-Gapped Environment
|
||||||
|
|
||||||
|
For restricted/disconnected environments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. First, push images to your internal registry
|
||||||
|
# Example using harbor.internal.example.com
|
||||||
|
|
||||||
|
# Pull images (on internet-connected machine)
|
||||||
|
docker pull postgres:15-alpine
|
||||||
|
docker pull minio/minio:latest
|
||||||
|
docker pull warehouse13/api:v1.0.0
|
||||||
|
docker pull warehouse13/frontend:v1.0.0
|
||||||
|
|
||||||
|
# Tag for internal registry
|
||||||
|
docker tag postgres:15-alpine harbor.internal.example.com/library/postgres:15-alpine
|
||||||
|
docker tag minio/minio:latest harbor.internal.example.com/library/minio:latest
|
||||||
|
docker tag warehouse13/api:v1.0.0 harbor.internal.example.com/warehouse13/api:v1.0.0
|
||||||
|
docker tag warehouse13/frontend:v1.0.0 harbor.internal.example.com/warehouse13/frontend:v1.0.0
|
||||||
|
|
||||||
|
# Push to internal registry
|
||||||
|
docker push harbor.internal.example.com/library/postgres:15-alpine
|
||||||
|
docker push harbor.internal.example.com/library/minio:latest
|
||||||
|
docker push harbor.internal.example.com/warehouse13/api:v1.0.0
|
||||||
|
docker push harbor.internal.example.com/warehouse13/frontend:v1.0.0
|
||||||
|
|
||||||
|
# 2. Update the values file with your registry
|
||||||
|
cp ./helm/warehouse13/values-airgapped.yaml ./my-airgapped-values.yaml
|
||||||
|
|
||||||
|
# Edit to match your environment:
|
||||||
|
# - Update all image.repository values
|
||||||
|
# - Set secure passwords
|
||||||
|
# - Configure storage classes
|
||||||
|
# - Add node selectors/tolerations if needed
|
||||||
|
|
||||||
|
# 3. Install on air-gapped cluster
|
||||||
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13 \
|
||||||
|
--create-namespace \
|
||||||
|
--values ./my-airgapped-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- All images from custom registry
|
||||||
|
- Local storage class support
|
||||||
|
- Node selectors for specific nodes
|
||||||
|
- Tolerations for tainted nodes
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Configurable Images
|
||||||
|
|
||||||
|
All component images can be customized:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# PostgreSQL
|
||||||
|
postgres:
|
||||||
|
image:
|
||||||
|
repository: postgres # or your-registry/postgres
|
||||||
|
tag: 15-alpine
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
minio:
|
||||||
|
image:
|
||||||
|
repository: minio/minio # or your-registry/minio
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# API Backend
|
||||||
|
api:
|
||||||
|
image:
|
||||||
|
repository: warehouse13/api # or your-registry/warehouse13-api
|
||||||
|
tag: v1.0.0
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend:
|
||||||
|
image:
|
||||||
|
repository: warehouse13/frontend # or your-registry/warehouse13-frontend
|
||||||
|
tag: v1.0.0
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Image Override
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Override images from command line
|
||||||
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
|
--set postgres.image.repository=myregistry.com/postgres \
|
||||||
|
--set postgres.image.tag=15-alpine \
|
||||||
|
--set minio.image.repository=myregistry.com/minio \
|
||||||
|
--set minio.image.tag=latest \
|
||||||
|
--set api.image.repository=myregistry.com/warehouse13-api \
|
||||||
|
--set api.image.tag=v1.0.0 \
|
||||||
|
--set frontend.image.repository=myregistry.com/warehouse13-frontend \
|
||||||
|
--set frontend.image.tag=v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# PostgreSQL storage
|
||||||
|
postgres:
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 50Gi
|
||||||
|
storageClass: "fast-ssd" # or "" for default
|
||||||
|
|
||||||
|
# MinIO storage
|
||||||
|
minio:
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 500Gi
|
||||||
|
storageClass: "bulk-storage" # or "" for default
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# API resources
|
||||||
|
api:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
|
||||||
|
# Frontend resources
|
||||||
|
frontend:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: "nginx"
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
hosts:
|
||||||
|
- host: warehouse13.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend: frontend
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend: api
|
||||||
|
tls:
|
||||||
|
- secretName: warehouse13-tls
|
||||||
|
hosts:
|
||||||
|
- warehouse13.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Deployment
|
||||||
|
|
||||||
|
### Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all pods are running
|
||||||
|
kubectl get pods -n warehouse13
|
||||||
|
|
||||||
|
# Check services
|
||||||
|
kubectl get svc -n warehouse13
|
||||||
|
|
||||||
|
# Check PVCs
|
||||||
|
kubectl get pvc -n warehouse13
|
||||||
|
|
||||||
|
# Check ingress (if enabled)
|
||||||
|
kubectl get ingress -n warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API logs
|
||||||
|
kubectl logs -n warehouse13 -l app.kubernetes.io/component=api --tail=100 -f
|
||||||
|
|
||||||
|
# Frontend logs
|
||||||
|
kubectl logs -n warehouse13 -l app.kubernetes.io/component=frontend --tail=100 -f
|
||||||
|
|
||||||
|
# PostgreSQL logs
|
||||||
|
kubectl logs -n warehouse13 warehouse13-postgres-0 --tail=100 -f
|
||||||
|
|
||||||
|
# MinIO logs
|
||||||
|
kubectl logs -n warehouse13 warehouse13-minio-0 --tail=100 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initialize MinIO Bucket
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Port-forward to MinIO console
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9001:9001
|
||||||
|
|
||||||
|
# Open http://localhost:9001
|
||||||
|
# Login with credentials from values.yaml
|
||||||
|
# Create bucket: "artifacts"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
### Upgrade to New Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update image tags in values file
|
||||||
|
# Then run upgrade
|
||||||
|
helm upgrade warehouse13 ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13 \
|
||||||
|
--values ./my-production-values.yaml \
|
||||||
|
--wait \
|
||||||
|
--timeout 10m
|
||||||
|
|
||||||
|
# Check rollout status
|
||||||
|
kubectl rollout status deployment/warehouse13-api -n warehouse13
|
||||||
|
kubectl rollout status deployment/warehouse13-frontend -n warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View revision history
|
||||||
|
helm history warehouse13 -n warehouse13
|
||||||
|
|
||||||
|
# Rollback to previous version
|
||||||
|
helm rollback warehouse13 -n warehouse13
|
||||||
|
|
||||||
|
# Rollback to specific revision
|
||||||
|
helm rollback warehouse13 2 -n warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Values Only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update configuration without changing images
|
||||||
|
helm upgrade warehouse13 ./helm/warehouse13 \
|
||||||
|
--namespace warehouse13 \
|
||||||
|
--values ./my-updated-values.yaml \
|
||||||
|
--reuse-values
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup and Restore
|
||||||
|
|
||||||
|
### PostgreSQL Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create backup
|
||||||
|
kubectl exec -n warehouse13 warehouse13-postgres-0 -- \
|
||||||
|
pg_dump -U warehouse13user warehouse13 > backup-$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
cat backup-20241016.sql | kubectl exec -i -n warehouse13 warehouse13-postgres-0 -- \
|
||||||
|
psql -U warehouse13user warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
### MinIO Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install MinIO Client
|
||||||
|
wget https://dl.min.io/client/mc/release/linux-amd64/mc
|
||||||
|
chmod +x mc
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
kubectl port-forward -n warehouse13 svc/warehouse13-minio 9000:9000
|
||||||
|
mc alias set w13 http://localhost:9000 <access-key> <secret-key>
|
||||||
|
|
||||||
|
# Backup bucket
|
||||||
|
mc mirror w13/artifacts ./backup/artifacts-$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
mc mirror ./backup/artifacts-20241016 w13/artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup all PVCs
|
||||||
|
for pvc in $(kubectl get pvc -n warehouse13 -o name); do
|
||||||
|
pvc_name=$(basename $pvc)
|
||||||
|
kubectl get -n warehouse13 $pvc -o yaml > backup-${pvc_name}.yaml
|
||||||
|
done
|
||||||
|
|
||||||
|
# Backup Helm values
|
||||||
|
helm get values warehouse13 -n warehouse13 > backup-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Pods Not Starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check pod status
|
||||||
|
kubectl get pods -n warehouse13
|
||||||
|
|
||||||
|
# Describe pod for events
|
||||||
|
kubectl describe pod <pod-name> -n warehouse13
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
kubectl logs <pod-name> -n warehouse13
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - ImagePullBackOff: Check image repository and credentials
|
||||||
|
# - Pending: Check PVC status and node resources
|
||||||
|
# - CrashLoopBackOff: Check application logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### PVC Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check PVC status
|
||||||
|
kubectl get pvc -n warehouse13
|
||||||
|
|
||||||
|
# Describe PVC
|
||||||
|
kubectl describe pvc <pvc-name> -n warehouse13
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - Pending: No storage class or insufficient storage
|
||||||
|
# - Bound: PVC is healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test PostgreSQL connection
|
||||||
|
kubectl exec -it -n warehouse13 warehouse13-postgres-0 -- \
|
||||||
|
psql -U warehouse13user -d warehouse13
|
||||||
|
|
||||||
|
# Check database logs
|
||||||
|
kubectl logs -n warehouse13 warehouse13-postgres-0 --tail=100
|
||||||
|
|
||||||
|
# Verify secret
|
||||||
|
kubectl get secret -n warehouse13 warehouse13-secrets -o yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress Not Working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check ingress status
|
||||||
|
kubectl get ingress -n warehouse13
|
||||||
|
kubectl describe ingress -n warehouse13 warehouse13-ingress
|
||||||
|
|
||||||
|
# Check ingress controller logs
|
||||||
|
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller
|
||||||
|
|
||||||
|
# Verify TLS certificate
|
||||||
|
kubectl get certificate -n warehouse13
|
||||||
|
kubectl describe certificate -n warehouse13 warehouse13-tls
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check resource usage
|
||||||
|
kubectl top pods -n warehouse13
|
||||||
|
kubectl top nodes
|
||||||
|
|
||||||
|
# Check if pods are being throttled
|
||||||
|
kubectl describe pod <pod-name> -n warehouse13 | grep -A 5 "State:"
|
||||||
|
|
||||||
|
# Increase resources
|
||||||
|
helm upgrade warehouse13 ./helm/warehouse13 \
|
||||||
|
--set api.resources.limits.memory=2Gi \
|
||||||
|
--set api.resources.limits.cpu=2000m
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Uninstall the release
|
||||||
|
helm uninstall warehouse13 -n warehouse13
|
||||||
|
|
||||||
|
# Delete PVCs (data will be lost!)
|
||||||
|
kubectl delete pvc -n warehouse13 -l app.kubernetes.io/instance=warehouse13
|
||||||
|
|
||||||
|
# Delete namespace
|
||||||
|
kubectl delete namespace warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Helm Chart README](./helm/warehouse13/README.md)
|
||||||
|
- [Values Documentation](./helm/warehouse13/values.yaml)
|
||||||
|
- [Docker Deployment Guide](./DEPLOYMENT.md)
|
||||||
|
- [Main README](./README.md)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- GitHub Issues: https://github.com/yourusername/warehouse13/issues
|
||||||
|
- Helm Chart Issues: Tag with `helm` label
|
||||||
220
docs/NPM-PACKAGE-AGE-POLICY.md
Normal file
220
docs/NPM-PACKAGE-AGE-POLICY.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# NPM Package Age Policy
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
All npm packages must be **at least 2 weeks old** before they can be used in this project. This ensures:
|
||||||
|
- Package stability
|
||||||
|
- Security vulnerability disclosure time
|
||||||
|
- Compliance with organizational security policies
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. Package Version Pinning
|
||||||
|
|
||||||
|
The project uses exact version pinning in `package.json` to prevent automatic updates:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/core": "19.2.7", // Exact version, not "^19.2.7" or "~19.2.7"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `frontend/.npmrc` file enforces this:
|
||||||
|
```
|
||||||
|
save-exact=true
|
||||||
|
package-lock=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Automated Age Checking
|
||||||
|
|
||||||
|
Use the provided script to verify all packages meet the 2-week requirement:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if all packages are at least 2 weeks old
|
||||||
|
node scripts/check-package-age.js
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
- Queries npm registry for publish dates
|
||||||
|
- Calculates age of each package
|
||||||
|
- Fails if any package is newer than 14 days
|
||||||
|
- Shows detailed age information for all packages
|
||||||
|
|
||||||
|
### 3. Installation Process
|
||||||
|
|
||||||
|
**Always use `npm ci` instead of `npm install`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci # Installs exact versions from package-lock.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why `npm ci`?**
|
||||||
|
- Uses exact versions from `package-lock.json`
|
||||||
|
- Doesn't update `package-lock.json`
|
||||||
|
- Ensures reproducible builds
|
||||||
|
- Faster than `npm install`
|
||||||
|
|
||||||
|
## Updating Packages
|
||||||
|
|
||||||
|
When you need to add or update packages:
|
||||||
|
|
||||||
|
### Step 1: Add Package to package.json
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find a version that's at least 2 weeks old
|
||||||
|
npm view <package-name> time
|
||||||
|
|
||||||
|
# Add exact version to package.json
|
||||||
|
"dependencies": {
|
||||||
|
"new-package": "1.2.3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Verify Age
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/check-package-age.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Update Lock File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install --package-lock-only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Install and Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Add the age check to your CI/CD pipeline:
|
||||||
|
|
||||||
|
### GitLab CI Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
verify_package_age:
|
||||||
|
stage: validate
|
||||||
|
image: node:18-alpine
|
||||||
|
script:
|
||||||
|
- node scripts/check-package-age.js
|
||||||
|
only:
|
||||||
|
- merge_requests
|
||||||
|
- main
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitHub Actions Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Check Package Age
|
||||||
|
run: node scripts/check-package-age.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Package is too new" Error
|
||||||
|
|
||||||
|
If a package fails the age check:
|
||||||
|
|
||||||
|
1. **Find an older version:**
|
||||||
|
```bash
|
||||||
|
npm view <package-name> versions --json
|
||||||
|
npm view <package-name>@<older-version> time
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update package.json with older version**
|
||||||
|
|
||||||
|
3. **Re-run age check:**
|
||||||
|
```bash
|
||||||
|
node scripts/check-package-age.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can't Find Old Enough Version
|
||||||
|
|
||||||
|
If no version meets the 2-week requirement:
|
||||||
|
- Wait until the package is at least 2 weeks old
|
||||||
|
- Look for alternative packages
|
||||||
|
- Request an exception through your security team
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Check current package ages
|
||||||
|
node scripts/check-package-age.js
|
||||||
|
|
||||||
|
# 2. If all pass, install dependencies
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# 3. Build application
|
||||||
|
npm run build:prod
|
||||||
|
|
||||||
|
# 4. For air-gapped deployment
|
||||||
|
../scripts/build-for-airgap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts Reference
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/check-package-age.js` | Verify all packages are ≥ 2 weeks old |
|
||||||
|
| `scripts/pin-old-versions.sh` | Helper script to validate and pin versions |
|
||||||
|
| `scripts/build-for-airgap.sh` | Build frontend for air-gapped deployment |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always commit `package-lock.json`**
|
||||||
|
- Ensures everyone uses the same versions
|
||||||
|
- Required for reproducible builds
|
||||||
|
|
||||||
|
2. **Use `npm ci` in CI/CD**
|
||||||
|
- Faster than `npm install`
|
||||||
|
- Enforces lock file versions
|
||||||
|
- Prevents surprises
|
||||||
|
|
||||||
|
3. **Regular audits**
|
||||||
|
```bash
|
||||||
|
# Check for security vulnerabilities
|
||||||
|
npm audit
|
||||||
|
|
||||||
|
# Check package ages
|
||||||
|
node scripts/check-package-age.js
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Version ranges to avoid**
|
||||||
|
- ❌ `^1.2.3` (allows minor/patch updates)
|
||||||
|
- ❌ `~1.2.3` (allows patch updates)
|
||||||
|
- ❌ `*` or `latest` (allows any version)
|
||||||
|
- ✅ `1.2.3` (exact version only)
|
||||||
|
|
||||||
|
## Package Age Check Output
|
||||||
|
|
||||||
|
```
|
||||||
|
Checking package ages (must be at least 2 weeks old)...
|
||||||
|
|
||||||
|
✓ @angular/common@19.2.7 - 45 days old
|
||||||
|
✓ @angular/compiler@19.2.7 - 45 days old
|
||||||
|
✓ rxjs@7.8.0 - 180 days old
|
||||||
|
❌ new-package@1.0.0 - 5 days old (published 2025-01-12)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
❌ FAILED: 1 package(s) are newer than 2 weeks:
|
||||||
|
|
||||||
|
- new-package@1.0.0 (5 days old, published 2025-01-12)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions or exceptions:
|
||||||
|
- Review with security team
|
||||||
|
- Document in project README
|
||||||
|
- Update this policy as needed
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Quick Start Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Test Artifact Data Lake platform provides several ways to run the application depending on your use case:
|
|
||||||
|
|
||||||
- **Development**: Backend services in Docker + Frontend dev server with hot reload
|
|
||||||
- **Production**: Complete stack in Docker containers
|
|
||||||
- **Testing**: Various rebuild and cleanup options
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- **Docker Desktop** (Windows/macOS) or **Docker** + **Docker Compose** (Linux)
|
|
||||||
- **Node.js 18+** (for development mode)
|
|
||||||
- **npm** (for development mode)
|
|
||||||
|
|
||||||
## Quick Start Options
|
|
||||||
|
|
||||||
### 1. Development Mode (Recommended for Development)
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh # Backend services only
|
|
||||||
./dev-start.sh # Backend + Frontend dev server
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```powershell
|
|
||||||
.\quickstart.ps1 # Backend services only
|
|
||||||
.\quickstart.ps1 -FullStack # Complete stack
|
|
||||||
.\quickstart.ps1 -Rebuild # Rebuild containers
|
|
||||||
.\dev-start.ps1 # Backend + Frontend dev server
|
|
||||||
```
|
|
||||||
|
|
||||||
**URLs:**
|
|
||||||
- Frontend: http://localhost:4200 (with hot reload)
|
|
||||||
- API: http://localhost:8000
|
|
||||||
- API Docs: http://localhost:8000/docs
|
|
||||||
- MinIO Console: http://localhost:9001
|
|
||||||
|
|
||||||
### 2. Production Mode (Complete Stack)
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh --full-stack
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```cmd
|
|
||||||
.\quickstart.ps1 -FullStack
|
|
||||||
```
|
|
||||||
|
|
||||||
**URLs:**
|
|
||||||
- Frontend: http://localhost:80 (production build)
|
|
||||||
- API: http://localhost:8000
|
|
||||||
- MinIO Console: http://localhost:9001
|
|
||||||
|
|
||||||
### 3. Force Rebuild (When Code Changes)
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh --rebuild # Rebuild backend only
|
|
||||||
./quickstart.sh --rebuild --full-stack # Rebuild complete stack
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```cmd
|
|
||||||
.\quickstart.ps1 -Rebuild # Rebuild backend only
|
|
||||||
.\quickstart.ps1 -Rebuild -FullStack # Rebuild complete stack
|
|
||||||
```
|
|
||||||
|
|
||||||
## Detailed Usage
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
|
|
||||||
1. **Start backend services:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start frontend in development mode:**
|
|
||||||
```bash
|
|
||||||
./dev-start.sh
|
|
||||||
```
|
|
||||||
Or manually:
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make changes to your code** - Frontend will auto-reload
|
|
||||||
|
|
||||||
4. **When backend code changes:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh --rebuild
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Testing
|
|
||||||
|
|
||||||
1. **Build and run complete stack:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh --full-stack
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test at http://localhost:80**
|
|
||||||
|
|
||||||
3. **When code changes:**
|
|
||||||
```bash
|
|
||||||
./quickstart.sh --rebuild --full-stack
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
### quickstart.sh / quickstart.ps1
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| (none) | Start backend services only (default) |
|
|
||||||
| `--full-stack` | Start complete stack including frontend |
|
|
||||||
| `--rebuild` | Force rebuild of containers |
|
|
||||||
| `--help` | Show help message |
|
|
||||||
|
|
||||||
### dev-start.sh / dev-start.ps1
|
|
||||||
|
|
||||||
Starts backend services + frontend development server with hot reload.
|
|
||||||
|
|
||||||
## Stopping Services
|
|
||||||
|
|
||||||
**Backend only:**
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
**Complete stack:**
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.production.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
**Backend services:**
|
|
||||||
```bash
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
**Complete stack:**
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.production.yml logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
**Specific service:**
|
|
||||||
```bash
|
|
||||||
docker-compose logs -f api
|
|
||||||
docker-compose logs -f postgres
|
|
||||||
docker-compose logs -f minio
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and modify as needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
The quickstart scripts will automatically create this file if it doesn't exist.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container Issues
|
|
||||||
- **Force rebuild:** Use `--rebuild` flag
|
|
||||||
- **Clean everything:** `docker-compose down --volumes --rmi all`
|
|
||||||
- **Check Docker:** Ensure Docker Desktop is running
|
|
||||||
|
|
||||||
### Frontend Issues
|
|
||||||
- **Dependencies:** Run `npm install` in `frontend/` directory
|
|
||||||
- **Port conflicts:** Check if port 4200 is available
|
|
||||||
- **Node version:** Ensure Node.js 18+ is installed
|
|
||||||
|
|
||||||
### Backend Issues
|
|
||||||
- **API not responding:** Wait longer for services to start (can take 30+ seconds)
|
|
||||||
- **Database issues:** Check `docker-compose logs postgres`
|
|
||||||
- **Storage issues:** Check `docker-compose logs minio`
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
| Feature | Development | Production |
|
|
||||||
|---------|-------------|------------|
|
|
||||||
| Frontend | Hot reload dev server | Built Angular app |
|
|
||||||
| Port | 4200 | 80 |
|
|
||||||
| Build time | Fast startup | Slower (builds Angular) |
|
|
||||||
| Use case | Development, testing | Demo, staging |
|
|
||||||
|
|
||||||
Choose the mode that best fits your workflow!
|
|
||||||
@@ -164,7 +164,7 @@ curl -X POST "http://localhost:8000/api/v1/artifacts/query" \
|
|||||||
make deploy
|
make deploy
|
||||||
|
|
||||||
# Or directly with Helm
|
# Or directly with Helm
|
||||||
helm install datalake ./helm --namespace datalake --create-namespace
|
helm install warehouse13 ./helm/warehouse13 --namespace warehouse13 --create-namespace
|
||||||
```
|
```
|
||||||
|
|
||||||
## Feature Flags Usage
|
## Feature Flags Usage
|
||||||
@@ -190,9 +190,8 @@ AWS_REGION=us-east-1
|
|||||||
S3_BUCKET_NAME=your-bucket
|
S3_BUCKET_NAME=your-bucket
|
||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
helm install datalake ./helm \
|
helm install warehouse13 ./helm/warehouse13 \
|
||||||
--set config.deploymentMode=cloud \
|
--set global.deploymentMode=cloud
|
||||||
--set aws.enabled=true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -36,6 +36,7 @@ yarn-error.log
|
|||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,151 +1,178 @@
|
|||||||
# Test Artifact Data Lake - Angular Frontend
|
# Warehouse13 Frontend - Angular Application
|
||||||
|
|
||||||
This is the Angular 19 frontend for the Test Artifact Data Lake application. It replaces the static HTML/JS implementation with a modern, component-based architecture.
|
Modern Angular application for the Warehouse13 Test Artifact Data Lake.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
✅ **Multi-component Architecture**: Built with reusable Angular components
|
- **Angular 20** with standalone components
|
||||||
✅ **Tab Navigation**: Clean tab-based interface for Artifacts, Upload, and Query
|
- **TypeScript** for type safety
|
||||||
✅ **Event ID Support**: Group multiple artifacts under the same event ID
|
- **Reactive Forms** for upload and query functionality
|
||||||
✅ **Expandable Binaries Display**: Show first 4 binaries, expandable for more
|
- **RxJS** for reactive programming
|
||||||
✅ **Advanced Tag Management**: Create tags on-the-spot with database persistence
|
- **Auto-refresh** artifacts every 5 seconds
|
||||||
✅ **Scoped Tags**: Organize tags by scope (project, environment, priority, etc.)
|
- **Client-side sorting and filtering**
|
||||||
✅ **Comprehensive Filtering**: Filter artifacts by all table criteria
|
- **Dark theme** UI
|
||||||
✅ **Real-time Search**: As-you-type filtering in query form
|
- **Responsive design**
|
||||||
✅ **Responsive Design**: Mobile-friendly interface
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
- **TabNavigationComponent**: Manages tab switching between Artifacts, Upload, and Query
|
|
||||||
- **ArtifactsTableComponent**: Displays artifacts with expandable binaries/tags and Event ID support
|
|
||||||
- **UploadFormComponent**: File upload with Event ID and binaries support
|
|
||||||
- **QueryFormComponent**: Advanced search with real-time filtering
|
|
||||||
- **TagManagerComponent**: On-the-spot tag creation with scoped tags
|
|
||||||
|
|
||||||
### Services
|
|
||||||
- **ArtifactService**: Handles all artifact-related API calls
|
|
||||||
- **ApiService**: Manages general API information
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js (v18 or later)
|
|
||||||
- Angular CLI 19
|
|
||||||
|
|
||||||
### Setup
|
- Node.js 24.x or higher
|
||||||
|
- npm 11.x or higher
|
||||||
|
- Backend API running on port 8000
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Server
|
### Run Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
# or
|
|
||||||
ng serve
|
|
||||||
```
|
```
|
||||||
The app will be available at `http://localhost:4200`
|
|
||||||
|
The application will be available at `http://localhost:4200/`
|
||||||
|
|
||||||
|
The development server includes a proxy configuration that forwards `/api` requests to `http://localhost:8000`.
|
||||||
|
|
||||||
### Build for Production
|
### Build for Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build:prod
|
||||||
# or
|
|
||||||
ng build
|
|
||||||
```
|
```
|
||||||
Built files will be in `dist/frontend/`
|
|
||||||
|
Build artifacts will be in the `dist/frontend/browser` directory.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── artifacts-list/ # Main artifacts table with sorting, filtering, auto-refresh
|
||||||
|
│ │ ├── upload-form/ # Reactive form for uploading artifacts
|
||||||
|
│ │ └── query-form/ # Advanced query interface
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── artifact.model.ts # TypeScript interfaces for type safety
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── artifact.ts # HTTP service for API calls
|
||||||
|
│ ├── app.ts # Main app component with routing
|
||||||
|
│ ├── app.config.ts # Application configuration
|
||||||
|
│ └── app.routes.ts # Route definitions
|
||||||
|
├── styles.css # Global dark theme styles
|
||||||
|
└── main.ts # Application bootstrap
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### ArtifactsListComponent
|
||||||
|
- Displays artifacts in a sortable, filterable table
|
||||||
|
- Auto-refreshes every 5 seconds (toggleable)
|
||||||
|
- Client-side search across all fields
|
||||||
|
- Download and delete actions
|
||||||
|
- Detail modal for full artifact information
|
||||||
|
- Tags displayed as inline badges
|
||||||
|
- SIM source grouping support
|
||||||
|
|
||||||
|
### UploadFormComponent
|
||||||
|
- Reactive form with validation
|
||||||
|
- File upload with drag-and-drop support
|
||||||
|
- Required fields: File, Sim Source, Uploaded By, Tags
|
||||||
|
- Optional fields: SIM Source ID (for grouping), Test Result, Version, Description, Test Config, Custom Metadata
|
||||||
|
- JSON validation for config fields
|
||||||
|
- Real-time upload status feedback
|
||||||
|
|
||||||
|
### QueryFormComponent
|
||||||
|
- Advanced search with multiple filter criteria
|
||||||
|
- Filter by: filename, file type, test name, test suite, test result, SIM source ID, tags, date range
|
||||||
|
- Results emitted to artifacts list
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
The frontend expects the backend API to be available at:
|
The frontend communicates with the FastAPI backend through the `ArtifactService`:
|
||||||
- Development: Same origin as the frontend
|
|
||||||
- Production: Configurable via environment files
|
|
||||||
|
|
||||||
### Required API Endpoints
|
- `GET /api/v1/artifacts/` - List all artifacts
|
||||||
- `GET /api` - API information
|
- `GET /api/v1/artifacts/:id` - Get single artifact
|
||||||
- `GET /api/v1/artifacts/` - List artifacts
|
- `POST /api/v1/artifacts/upload` - Upload new artifact
|
||||||
- `GET /api/v1/artifacts/{id}` - Get artifact details
|
- `POST /api/v1/artifacts/query` - Query with filters
|
||||||
- `POST /api/v1/artifacts/upload` - Upload artifact
|
- `GET /api/v1/artifacts/:id/download` - Download artifact file
|
||||||
- `DELETE /api/v1/artifacts/{id}` - Delete artifact
|
- `DELETE /api/v1/artifacts/:id` - Delete artifact
|
||||||
- `GET /api/v1/artifacts/{id}/download` - Download artifact
|
- `POST /api/v1/seed/generate/:count` - Generate seed data
|
||||||
- `POST /api/v1/artifacts/query` - Query artifacts
|
|
||||||
- `POST /api/v1/seed/generate/{count}` - Generate seed data
|
|
||||||
- `GET /api/v1/tags` - List all tags
|
|
||||||
- `POST /api/v1/tags` - Create tag
|
|
||||||
- `POST /api/v1/artifacts/{id}/tags` - Add tag to artifact
|
|
||||||
- `DELETE /api/v1/artifacts/{id}/tags/{tag_id}` - Remove tag from artifact
|
|
||||||
|
|
||||||
## Key Features Implementation
|
## Configuration
|
||||||
|
|
||||||
### Event ID Support
|
### Proxy Configuration (`proxy.conf.json`)
|
||||||
Each artifact can be assigned an Event ID to group related artifacts together. This is displayed prominently in the table and can be used for filtering.
|
|
||||||
|
|
||||||
### Expandable Binaries
|
```json
|
||||||
When an artifact has more than 4 associated binaries, only the first 4 are shown with a "+X more" button to expand and see all binaries.
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Advanced Tag Management
|
This proxies all `/api` requests to the backend during development.
|
||||||
- Create tags on-the-spot directly in the table
|
|
||||||
- Organize tags by scope (project, environment, priority, category, status)
|
|
||||||
- Tags persist in the database across app restarts
|
|
||||||
- Visual indicators show which tags are already attached
|
|
||||||
- Quick-add existing tags from a categorized list
|
|
||||||
|
|
||||||
### Comprehensive Filtering
|
## Styling
|
||||||
The query form provides real-time filtering by:
|
|
||||||
- Filename (partial match)
|
|
||||||
- File type
|
|
||||||
- Test name
|
|
||||||
- Test suite
|
|
||||||
- Test result
|
|
||||||
- Tags (comma-separated)
|
|
||||||
- Date range
|
|
||||||
|
|
||||||
Filters are applied immediately as you type, and active filters are displayed as visual chips.
|
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
|
||||||
|
|
||||||
## Architecture Improvements
|
## Browser Support
|
||||||
|
|
||||||
### From Static to Angular
|
- Chrome/Edge (latest)
|
||||||
The original static JavaScript implementation has been converted to:
|
- Firefox (latest)
|
||||||
|
- Safari (latest)
|
||||||
|
|
||||||
1. **Component-based Architecture**: Each major feature is now a reusable component
|
## Development Tips
|
||||||
2. **Type Safety**: Full TypeScript support with proper interfaces
|
|
||||||
3. **Reactive Programming**: Uses RxJS observables for API calls
|
|
||||||
4. **State Management**: Centralized state management through services
|
|
||||||
5. **Modular Design**: Easy to extend and maintain
|
|
||||||
|
|
||||||
### Benefits
|
1. **Hot Reload**: Changes to TypeScript files automatically trigger recompilation
|
||||||
- **Maintainability**: Clear separation of concerns
|
2. **Type Safety**: Use TypeScript interfaces in `models/` for all API responses
|
||||||
- **Reusability**: Components can be reused and extended
|
3. **State Management**: Currently using component-level state; consider NgRx for complex state
|
||||||
- **Testing**: Angular's testing framework support
|
4. **Testing**: Run `npm test` for unit tests (Jasmine/Karma)
|
||||||
- **Performance**: Optimized change detection and lazy loading
|
|
||||||
- **Developer Experience**: Hot reload, TypeScript, and Angular DevTools
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Development
|
For production deployment, build the application and serve the `dist/frontend/browser` directory with your web server (nginx, Apache, etc.).
|
||||||
The Angular frontend can be served during development using `ng serve` and will proxy API calls to the backend.
|
|
||||||
|
|
||||||
### Production
|
Example nginx configuration:
|
||||||
Build the application and serve the static files from any web server. Ensure the backend API is accessible from the same domain or configure CORS appropriately.
|
|
||||||
|
|
||||||
### Integration with Existing Backend
|
```nginx
|
||||||
The Angular frontend is designed to be a drop-in replacement for the static frontend. Simply:
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
root /path/to/dist/frontend/browser;
|
||||||
|
|
||||||
1. Build the Angular app: `npm run build`
|
location / {
|
||||||
2. Copy contents of `dist/frontend/` to your static files directory
|
try_files $uri $uri/ /index.html;
|
||||||
3. Update your backend to serve the new `index.html`
|
}
|
||||||
4. Ensure API endpoints match the expected interface
|
|
||||||
|
|
||||||
## Browser Support
|
location /api {
|
||||||
- Chrome (latest)
|
proxy_pass http://backend:8000;
|
||||||
- Firefox (latest)
|
}
|
||||||
- Safari (latest)
|
}
|
||||||
- Edge (latest)
|
```
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
- Drag and drop file upload
|
|
||||||
- Bulk operations
|
- [ ] Add NgRx for state management
|
||||||
- Advanced data visualization
|
- [ ] Implement WebSocket for real-time updates
|
||||||
- Real-time updates via WebSocket
|
- [ ] Add Angular Material components
|
||||||
- Export functionality
|
- [ ] Unit and E2E tests
|
||||||
- User authentication integration
|
- [ ] PWA support
|
||||||
|
- [ ] Drag-and-drop file upload
|
||||||
|
- [ ] Bulk operations
|
||||||
|
- [ ] Export to CSV/JSON
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Same as parent project
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
|
||||||
"packageManager": "npm",
|
|
||||||
"analytics": false
|
|
||||||
},
|
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {},
|
||||||
"@schematics/angular:component": {
|
|
||||||
"style": "scss"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "",
|
"root": "",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/frontend",
|
"outputPath": "dist/frontend",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
@@ -28,7 +20,6 @@
|
|||||||
"zone.js"
|
"zone.js"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
@@ -36,13 +27,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
"src/styles.css"
|
||||||
"src/styles.scss"
|
]
|
||||||
],
|
|
||||||
"scripts": []
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"optimization" : {
|
||||||
|
"fonts": false
|
||||||
|
},
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
@@ -66,10 +58,7 @@
|
|||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
|
||||||
"proxyConfig": "proxy.conf.json"
|
|
||||||
},
|
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "frontend:build:production"
|
"buildTarget": "frontend:build:production"
|
||||||
@@ -81,17 +70,16 @@
|
|||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
"builder": "@angular/build:extract-i18n"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular/build:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js",
|
"zone.js",
|
||||||
"zone.js/testing"
|
"zone.js/testing"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
@@ -99,13 +87,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
"src/styles.css"
|
||||||
"src/styles.scss"
|
]
|
||||||
],
|
|
||||||
"scripts": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15273
frontend/package-lock.json
generated
15273
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,37 +3,81 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"build:prod": "ng build --configuration production",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
},
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^19.2.19",
|
"@angular/common": "19.2.x",
|
||||||
"@angular/common": "^19.2.0",
|
"@angular/compiler": "19.2.x",
|
||||||
"@angular/compiler": "^19.2.0",
|
"@angular/core": "19.2.x",
|
||||||
"@angular/core": "^19.2.0",
|
"@angular/forms": "19.2.x",
|
||||||
"@angular/forms": "^19.2.0",
|
"@angular/platform-browser": "19.2.x",
|
||||||
"@angular/material": "^19.2.19",
|
"@angular/router": "19.2.x",
|
||||||
"@angular/platform-browser": "^19.2.0",
|
|
||||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
|
||||||
"@angular/router": "^19.2.0",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.8.1",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.2.17",
|
"@angular/build": "19.2.x",
|
||||||
"@angular/cli": "^19.2.17",
|
"@angular/cli": "19.2.x",
|
||||||
"@angular/compiler-cli": "^19.2.0",
|
"@angular/compiler-cli": "19.2.x",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"jasmine-core": "~5.6.0",
|
"jasmine-core": "~5.9.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~5.7.2"
|
"typescript": "5.x.x",
|
||||||
|
"undici-types": "7.12.0",
|
||||||
|
"node-releases": "2.0.21",
|
||||||
|
"node-gyp": "11.4.2",
|
||||||
|
"tar": "7.4.3",
|
||||||
|
"minizlib": "3.0.2",
|
||||||
|
"immutable": "5.1.3",
|
||||||
|
"exponential-backoff": "3.1.2",
|
||||||
|
"emoji-regex": "10.5.0",
|
||||||
|
"electron-to-chromium": "1.5.221",
|
||||||
|
"caniuse-lite": "1.0.30001743"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"undici-types": "7.12.0",
|
||||||
|
"node-releases": "2.0.21",
|
||||||
|
"node-gyp": "11.4.2",
|
||||||
|
"tar": "7.4.3",
|
||||||
|
"minizlib": "3.0.2",
|
||||||
|
"immutable": "5.1.3",
|
||||||
|
"exponential-backoff": "3.1.2",
|
||||||
|
"emoji-regex": "10.5.0",
|
||||||
|
"electron-to-chromium": "1.5.221",
|
||||||
|
"caniuse-lite": "1.0.30001743"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"undici-types": "7.12.0",
|
||||||
|
"node-releases": "2.0.21",
|
||||||
|
"node-gyp": "11.4.2",
|
||||||
|
"tar": "7.4.3",
|
||||||
|
"minizlib": "3.0.2",
|
||||||
|
"immutable": "5.1.3",
|
||||||
|
"exponential-backoff": "3.1.2",
|
||||||
|
"emoji-regex": "10.5.0",
|
||||||
|
"electron-to-chromium": "1.5.221",
|
||||||
|
"caniuse-lite": "1.0.30001743"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
"/api": {
|
"/api": {
|
||||||
"target": "http://localhost:8000",
|
"target": "http://localhost:8000",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true
|
||||||
"logLevel": "debug",
|
|
||||||
"pathRewrite": {
|
|
||||||
"^/api": "/api"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
<div class="app-container">
|
|
||||||
<!-- Material Toolbar Header -->
|
|
||||||
<mat-toolbar color="primary" class="app-toolbar">
|
|
||||||
<mat-icon class="app-icon">storage</mat-icon>
|
|
||||||
<span class="app-title">{{ title }}</span>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<div class="header-info" *ngIf="apiInfo">
|
|
||||||
<mat-chip-set>
|
|
||||||
<mat-chip>
|
|
||||||
<mat-icon matChipAvatar>settings</mat-icon>
|
|
||||||
{{ apiInfo.deployment_mode }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip>
|
|
||||||
<mat-icon matChipAvatar>folder</mat-icon>
|
|
||||||
{{ apiInfo.storage_backend }}
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</mat-toolbar>
|
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<mat-tab-group [(selectedIndex)]="selectedTabIndex" class="main-tabs">
|
|
||||||
<!-- Artifacts Tab -->
|
|
||||||
<mat-tab label="Artifacts">
|
|
||||||
<ng-template matTabContent>
|
|
||||||
<div class="tab-content-wrapper">
|
|
||||||
<div class="content-header">
|
|
||||||
<h2>
|
|
||||||
<mat-icon>storage</mat-icon>
|
|
||||||
Artifacts ({{ artifacts.length }})
|
|
||||||
</h2>
|
|
||||||
<button mat-raised-button color="primary" (click)="loadArtifacts()">
|
|
||||||
<mat-icon>refresh</mat-icon>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table mat-table [dataSource]="artifacts" class="artifacts-table mat-elevation-z4">
|
|
||||||
<!-- ID Column -->
|
|
||||||
<ng-container matColumnDef="id">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">{{ artifact.id }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Filename Column -->
|
|
||||||
<ng-container matColumnDef="filename">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Filename</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact" class="filename-cell">
|
|
||||||
<mat-icon class="file-icon">description</mat-icon>
|
|
||||||
{{ artifact.filename }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Type Column -->
|
|
||||||
<ng-container matColumnDef="file_type">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<mat-chip class="type-chip">{{ artifact.file_type }}</mat-chip>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Size Column -->
|
|
||||||
<ng-container matColumnDef="file_size">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Size</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">{{ formatBytes(artifact.file_size) }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Test Name Column -->
|
|
||||||
<ng-container matColumnDef="test_name">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Test Name</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">{{ artifact.test_name || '-' }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Test Result Column -->
|
|
||||||
<ng-container matColumnDef="test_result">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Result</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<mat-chip *ngIf="artifact.test_result" [class]="'result-' + artifact.test_result">
|
|
||||||
{{ artifact.test_result }}
|
|
||||||
</mat-chip>
|
|
||||||
<span *ngIf="!artifact.test_result" class="text-muted">-</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
|
|
||||||
<!-- Upload Tab -->
|
|
||||||
<mat-tab label="Upload">
|
|
||||||
<ng-template matTabContent>
|
|
||||||
<div class="tab-content-wrapper">
|
|
||||||
<div class="content-header">
|
|
||||||
<h2>
|
|
||||||
<mat-icon>cloud_upload</mat-icon>
|
|
||||||
Upload Artifacts
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<app-upload-form (uploadSuccess)="onUploadSuccess()"></app-upload-form>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
|
|
||||||
<!-- Query Tab -->
|
|
||||||
<mat-tab label="Query">
|
|
||||||
<ng-template matTabContent>
|
|
||||||
<div class="tab-content-wrapper">
|
|
||||||
<div class="content-header">
|
|
||||||
<h2>
|
|
||||||
<mat-icon>search</mat-icon>
|
|
||||||
Query Artifacts
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<app-query-form
|
|
||||||
(queryResults)="onQueryResults($event)"
|
|
||||||
(filtersChange)="onFiltersChange($event)">
|
|
||||||
</app-query-form>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
</mat-tab-group>
|
|
||||||
</div>
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
.app-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-toolbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-icon {
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-info {
|
|
||||||
mat-chip-set {
|
|
||||||
mat-chip {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
|
||||||
color: white !important;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab Group Styling
|
|
||||||
.main-tabs {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: white;
|
|
||||||
|
|
||||||
::ng-deep {
|
|
||||||
.mat-mdc-tab-body-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-tab-header {
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-tab-labels {
|
|
||||||
padding: 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-tab {
|
|
||||||
min-width: 120px;
|
|
||||||
height: 56px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab Content Wrapper
|
|
||||||
.tab-content-wrapper {
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
min-height: calc(100vh - 180px);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content Header
|
|
||||||
.content-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
color: #3f51b5;
|
|
||||||
font-size: 28px;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
mat-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artifacts Table Styling
|
|
||||||
.artifacts-table {
|
|
||||||
width: 100%;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
th.mat-mdc-header-cell {
|
|
||||||
background: #f8f9fa;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
padding: 16px 12px;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.mat-mdc-cell {
|
|
||||||
padding: 16px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #555;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.mat-mdc-row {
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filename-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
color: #3f51b5;
|
|
||||||
font-size: 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-chip {
|
|
||||||
background-color: #e3f2fd !important;
|
|
||||||
color: #1976d2 !important;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result Chips
|
|
||||||
.result-pass {
|
|
||||||
background-color: #e8f5e9 !important;
|
|
||||||
color: #2e7d32 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-fail {
|
|
||||||
background-color: #ffebee !important;
|
|
||||||
color: #c62828 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-skip {
|
|
||||||
background-color: #fff3e0 !important;
|
|
||||||
color: #ef6c00 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-error {
|
|
||||||
background-color: #fce4ec !important;
|
|
||||||
color: #c2185b !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive Design
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tab-content-wrapper {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-info {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifacts-table {
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
th.mat-mdc-header-cell,
|
|
||||||
td.mat-mdc-cell {
|
|
||||||
padding: 12px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.artifacts-table {
|
|
||||||
th.mat-mdc-header-cell:nth-child(n+5),
|
|
||||||
td.mat-mdc-cell:nth-child(n+5) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
|
||||||
import { MatTableModule } from '@angular/material/table';
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
|
||||||
import { QueryFormComponent } from './components/query-form/query-form.component';
|
|
||||||
import { ApiService } from './services/api.service';
|
|
||||||
import { ArtifactService } from './services/artifact.service';
|
|
||||||
import { ApiInfo, Artifact } from './models/artifact.interface';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
MatToolbarModule,
|
|
||||||
MatTableModule,
|
|
||||||
MatTabsModule,
|
|
||||||
MatChipsModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatButtonModule,
|
|
||||||
UploadFormComponent,
|
|
||||||
QueryFormComponent
|
|
||||||
],
|
|
||||||
templateUrl: './app.component.html',
|
|
||||||
styleUrl: './app.component.scss'
|
|
||||||
})
|
|
||||||
export class AppComponent implements OnInit {
|
|
||||||
title = 'Test Artifact Data Lake';
|
|
||||||
apiInfo: ApiInfo | null = null;
|
|
||||||
artifacts: Artifact[] = [];
|
|
||||||
displayedColumns: string[] = ['id', 'filename', 'file_type', 'file_size', 'test_name', 'test_result'];
|
|
||||||
selectedTabIndex = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private artifactService: ArtifactService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadApiInfo();
|
|
||||||
this.loadArtifacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadApiInfo(): void {
|
|
||||||
this.apiService.getApiInfo().subscribe({
|
|
||||||
next: (info) => {
|
|
||||||
this.apiInfo = info;
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error loading API info:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadArtifacts(): void {
|
|
||||||
this.artifactService.getArtifacts().subscribe({
|
|
||||||
next: (artifacts) => {
|
|
||||||
console.log('Loaded artifacts:', artifacts.length);
|
|
||||||
this.artifacts = artifacts;
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error loading artifacts:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onUploadSuccess(): void {
|
|
||||||
this.loadArtifacts();
|
|
||||||
this.selectedTabIndex = 0; // Switch back to artifacts tab
|
|
||||||
}
|
|
||||||
|
|
||||||
onQueryResults(artifacts: Artifact[]): void {
|
|
||||||
this.artifacts = artifacts;
|
|
||||||
this.selectedTabIndex = 0; // Switch to artifacts tab to show results
|
|
||||||
}
|
|
||||||
|
|
||||||
onFiltersChange(filters: any): void {
|
|
||||||
// Filters will be handled by the query component
|
|
||||||
console.log('Filters changed:', filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
134
frontend/src/app/app.css
Normal file
134
frontend/src/app/app.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/* App Layout */
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-right: 280px;
|
||||||
|
transition: margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content.sidebar-collapsed {
|
||||||
|
margin-right: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Header */
|
||||||
|
.top-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-header h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.main-content {
|
||||||
|
margin-right: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth Scrolling */
|
||||||
|
.content-area::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(59, 130, 246, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Material Icons Sizing */
|
||||||
|
.material-icons.md-16 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-18 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-20 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-24 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
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 />
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { ArtifactsListComponent } from './components/artifacts-list/artifacts-list';
|
||||||
|
import { UploadFormComponent } from './components/upload-form/upload-form';
|
||||||
|
import { QueryFormComponent } from './components/query-form/query-form';
|
||||||
|
import { SettingsComponent } from './components/settings/settings';
|
||||||
|
import { ProfileComponent } from './components/profile/profile';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [
|
||||||
|
{ path: '', redirectTo: '/artifacts', pathMatch: 'full' },
|
||||||
|
{ path: 'artifacts', component: ArtifactsListComponent },
|
||||||
|
{ path: 'upload', component: UploadFormComponent },
|
||||||
|
{ path: 'query', component: QueryFormComponent },
|
||||||
|
{ path: 'settings', component: SettingsComponent },
|
||||||
|
{ path: 'profile', component: ProfileComponent }
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { AppComponent } from './app.component';
|
import { App } from './app';
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppComponent],
|
imports: [App],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create the app', () => {
|
it('should create the app', () => {
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should have the 'frontend' title`, () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app.title).toEqual('frontend');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render title', () => {
|
it('should render title', () => {
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(App);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||||
52
frontend/src/app/app.ts
Normal file
52
frontend/src/app/app.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { ArtifactService } from './services/artifact';
|
||||||
|
import { NavSidebarComponent } from './components/nav-sidebar/nav-sidebar';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterOutlet, NavSidebarComponent],
|
||||||
|
template: `
|
||||||
|
<div class="app-layout">
|
||||||
|
<app-nav-sidebar (sidebarToggled)="onSidebarToggle($event)"></app-nav-sidebar>
|
||||||
|
|
||||||
|
<main class="main-content" [class.sidebar-collapsed]="isSidebarCollapsed">
|
||||||
|
<header class="top-header">
|
||||||
|
<h1><span class="logo">[W13]</span> Warehouse13</h1>
|
||||||
|
<div class="header-info">
|
||||||
|
<span class="badge">{{ deploymentMode }}</span>
|
||||||
|
<span class="badge">{{ storageBackend }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-area">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrls: ['./app.css']
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit {
|
||||||
|
deploymentMode: string = '';
|
||||||
|
storageBackend: string = '';
|
||||||
|
isSidebarCollapsed: boolean = false;
|
||||||
|
|
||||||
|
constructor(private artifactService: ArtifactService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.artifactService.getApiInfo().subscribe({
|
||||||
|
next: (info) => {
|
||||||
|
this.deploymentMode = `Mode: ${info.deployment_mode}`;
|
||||||
|
this.storageBackend = `Storage: ${info.storage_backend}`;
|
||||||
|
},
|
||||||
|
error: (err) => console.error('Failed to load API info:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSidebarToggle(isCollapsed: boolean) {
|
||||||
|
this.isSidebarCollapsed = isCollapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
<div class="artifacts-section">
|
|
||||||
<!-- Debug Loading State -->
|
|
||||||
<div style="background: red; color: white; padding: 10px; margin: 10px;">
|
|
||||||
LOADING STATE: {{ loading ? 'TRUE (Loading...)' : 'FALSE (Not Loading)' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<mat-card class="toolbar-card">
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="toolbar">
|
|
||||||
<div class="toolbar-buttons">
|
|
||||||
<button mat-raised-button color="primary" (click)="loadArtifacts()">
|
|
||||||
<mat-icon>refresh</mat-icon>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button mat-raised-button color="accent" (click)="generateSeedData()">
|
|
||||||
<mat-icon>scatter_plot</mat-icon>
|
|
||||||
Generate Seed Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<mat-chip-set class="count-chip">
|
|
||||||
<mat-chip>
|
|
||||||
<mat-icon matChipAvatar>storage</mat-icon>
|
|
||||||
{{ filteredArtifacts.length }} artifacts
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- Loading Spinner -->
|
|
||||||
<div *ngIf="loading" class="loading-container">
|
|
||||||
<mat-spinner diameter="50"></mat-spinner>
|
|
||||||
<p>Loading artifacts...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Simple List Test (No Material Components) -->
|
|
||||||
<div *ngIf="!loading" style="background: lightgreen; padding: 20px; margin: 20px;">
|
|
||||||
<h3>Simple List Test</h3>
|
|
||||||
<p><strong>Filtered Artifacts Count:</strong> {{ filteredArtifacts.length }}</p>
|
|
||||||
<div *ngFor="let artifact of filteredArtifacts.slice(0, 5)" style="border: 1px solid black; padding: 10px; margin: 5px;">
|
|
||||||
<strong>ID:</strong> {{ artifact.id }} |
|
|
||||||
<strong>Filename:</strong> {{ artifact.filename }} |
|
|
||||||
<strong>Type:</strong> {{ artifact.file_type }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Material Table -->
|
|
||||||
<mat-card *ngIf="!loading" class="table-card">
|
|
||||||
<div class="table-container">
|
|
||||||
<table mat-table [dataSource]="filteredArtifacts" class="artifacts-table">
|
|
||||||
|
|
||||||
<!-- ID Column -->
|
|
||||||
<ng-container matColumnDef="id">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<strong>{{ artifact.id }}</strong>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Event ID Column -->
|
|
||||||
<ng-container matColumnDef="eventId">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Event ID</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<mat-chip *ngIf="artifact.event_id" color="primary">
|
|
||||||
{{ artifact.event_id }}
|
|
||||||
</mat-chip>
|
|
||||||
<span *ngIf="!artifact.event_id" class="text-muted">{{ artifact.id }}</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Filename Column -->
|
|
||||||
<ng-container matColumnDef="filename">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Filename</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<button mat-button (click)="showDetail(artifact)" class="filename-link">
|
|
||||||
<mat-icon>description</mat-icon>
|
|
||||||
{{ artifact.filename }}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Type Column -->
|
|
||||||
<ng-container matColumnDef="type">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<mat-chip class="type-chip">{{ artifact.file_type }}</mat-chip>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Size Column -->
|
|
||||||
<ng-container matColumnDef="size">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Size</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">{{ formatBytes(artifact.file_size) }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Binaries Column -->
|
|
||||||
<ng-container matColumnDef="binaries">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Binaries</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact" class="binaries-cell">
|
|
||||||
<div *ngIf="artifact.binaries && artifact.binaries.length > 0; else noBinaries">
|
|
||||||
<mat-chip-set>
|
|
||||||
<mat-chip *ngFor="let binary of getVisibleBinaries(artifact.binaries)" class="binary-chip">
|
|
||||||
<mat-icon matChipAvatar>code</mat-icon>
|
|
||||||
{{ binary }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip
|
|
||||||
*ngIf="getHiddenBinariesCount(artifact.binaries) > 0"
|
|
||||||
(click)="toggleBinariesExpansion(artifact.id)"
|
|
||||||
class="expand-chip">
|
|
||||||
<span *ngIf="!expandedBinaries[artifact.id]">
|
|
||||||
+{{ getHiddenBinariesCount(artifact.binaries) }} more
|
|
||||||
</span>
|
|
||||||
<span *ngIf="expandedBinaries[artifact.id]">- less</span>
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
<mat-chip-set *ngIf="expandedBinaries[artifact.id]" class="expanded-binaries">
|
|
||||||
<mat-chip *ngFor="let binary of artifact.binaries.slice(4)" class="binary-chip">
|
|
||||||
<mat-icon matChipAvatar>code</mat-icon>
|
|
||||||
{{ binary }}
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
<ng-template #noBinaries>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
</ng-template>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Test Name Column -->
|
|
||||||
<ng-container matColumnDef="testName">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Test Name</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<span>{{ artifact.test_name || '-' }}</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Suite Column -->
|
|
||||||
<ng-container matColumnDef="suite">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Suite</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<span>{{ artifact.test_suite || '-' }}</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Result Column -->
|
|
||||||
<ng-container matColumnDef="result">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Result</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<mat-chip
|
|
||||||
*ngIf="artifact.test_result"
|
|
||||||
[class]="'result-' + artifact.test_result">
|
|
||||||
<mat-icon matChipAvatar>{{ getResultIcon(artifact.test_result) }}</mat-icon>
|
|
||||||
{{ artifact.test_result }}
|
|
||||||
</mat-chip>
|
|
||||||
<span *ngIf="!artifact.test_result" class="text-muted">-</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Tags Column -->
|
|
||||||
<ng-container matColumnDef="tags">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Tags</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact" class="tags-cell">
|
|
||||||
<div *ngIf="artifact.tags && artifact.tags.length > 0; else noTags">
|
|
||||||
<mat-chip-set>
|
|
||||||
<mat-chip *ngFor="let tag of getVisibleTags(artifact.tags)" class="tag-chip">
|
|
||||||
{{ tag }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip
|
|
||||||
*ngIf="getHiddenTagsCount(artifact.tags) > 0"
|
|
||||||
(click)="toggleTagsExpansion(artifact.id)"
|
|
||||||
class="expand-chip">
|
|
||||||
<span *ngIf="!expandedTags[artifact.id]">
|
|
||||||
+{{ getHiddenTagsCount(artifact.tags) }} more
|
|
||||||
</span>
|
|
||||||
<span *ngIf="expandedTags[artifact.id]">- less</span>
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
<mat-chip-set *ngIf="expandedTags[artifact.id]" class="expanded-tags">
|
|
||||||
<mat-chip *ngFor="let tag of artifact.tags.slice(3)" class="tag-chip">
|
|
||||||
{{ tag }}
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
<app-tag-manager
|
|
||||||
[artifactId]="artifact.id"
|
|
||||||
[currentTags]="artifact.tags"
|
|
||||||
(tagsUpdated)="onTagsUpdated()">
|
|
||||||
</app-tag-manager>
|
|
||||||
</div>
|
|
||||||
<ng-template #noTags>
|
|
||||||
<app-tag-manager
|
|
||||||
[artifactId]="artifact.id"
|
|
||||||
[currentTags]="[]"
|
|
||||||
(tagsUpdated)="onTagsUpdated()">
|
|
||||||
</app-tag-manager>
|
|
||||||
</ng-template>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Created Column -->
|
|
||||||
<ng-container matColumnDef="created">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Created</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<small>{{ formatDate(artifact.created_at) }}</small>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Actions Column -->
|
|
||||||
<ng-container matColumnDef="actions">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
|
||||||
<td mat-cell *matCellDef="let artifact">
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button mat-icon-button
|
|
||||||
(click)="downloadArtifact(artifact)"
|
|
||||||
matTooltip="Download"
|
|
||||||
color="primary">
|
|
||||||
<mat-icon>download</mat-icon>
|
|
||||||
</button>
|
|
||||||
<button mat-icon-button
|
|
||||||
(click)="deleteArtifact(artifact)"
|
|
||||||
matTooltip="Delete"
|
|
||||||
color="warn">
|
|
||||||
<mat-icon>delete</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Table Header and Rows -->
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
|
||||||
|
|
||||||
<!-- No Data Row -->
|
|
||||||
<tr class="mat-row" *matNoDataRow>
|
|
||||||
<td class="mat-cell no-data" [attr.colspan]="displayedColumns.length">
|
|
||||||
<div class="no-data-content">
|
|
||||||
<mat-icon>inbox</mat-icon>
|
|
||||||
<p>No artifacts found. Upload some files to get started!</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<mat-card class="pagination-card" *ngIf="!loading">
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="pagination">
|
|
||||||
<button mat-icon-button
|
|
||||||
(click)="previousPage()"
|
|
||||||
[disabled]="currentPage === 1"
|
|
||||||
matTooltip="Previous page">
|
|
||||||
<mat-icon>chevron_left</mat-icon>
|
|
||||||
</button>
|
|
||||||
<mat-chip>Page {{ currentPage }}</mat-chip>
|
|
||||||
<button mat-icon-button
|
|
||||||
(click)="nextPage()"
|
|
||||||
[disabled]="filteredArtifacts.length < pageSize"
|
|
||||||
matTooltip="Next page">
|
|
||||||
<mat-icon>chevron_right</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Artifact Detail Modal (placeholder for now) -->
|
|
||||||
<div *ngIf="showDetailModal && selectedArtifact" class="detail-backdrop" (click)="closeDetailModal()">
|
|
||||||
<mat-card class="detail-modal" (click)="$event.stopPropagation()">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>Artifact Details</mat-card-title>
|
|
||||||
<button mat-icon-button (click)="closeDetailModal()" class="close-button">
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
</button>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
|
||||||
<!-- Detail content will be added later -->
|
|
||||||
<p>Artifact ID: {{ selectedArtifact.id }}</p>
|
|
||||||
<p>Filename: {{ selectedArtifact.filename }}</p>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
.artifacts-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-chip {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #666;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifacts-table {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.mat-mdc-cell,
|
|
||||||
.mat-mdc-header-cell {
|
|
||||||
padding: 12px 8px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-header-cell {
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-row:hover {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column specific styles
|
|
||||||
.binaries-cell,
|
|
||||||
.tags-cell {
|
|
||||||
max-width: 250px;
|
|
||||||
|
|
||||||
mat-chip-set {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filename-link {
|
|
||||||
text-align: left;
|
|
||||||
justify-content: flex-start;
|
|
||||||
text-transform: none;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: #999;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chip styles
|
|
||||||
.type-chip {
|
|
||||||
background-color: #e3f2fd !important;
|
|
||||||
color: #1976d2 !important;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binary-chip {
|
|
||||||
background-color: #f3e5f5 !important;
|
|
||||||
color: #7b1fa2 !important;
|
|
||||||
font-size: 10px;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-chip {
|
|
||||||
background-color: #e8f5e8 !important;
|
|
||||||
color: #2e7d32 !important;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-chip {
|
|
||||||
background-color: #fff3e0 !important;
|
|
||||||
color: #f57c00 !important;
|
|
||||||
font-size: 9px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #ffe0b2 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-binaries,
|
|
||||||
.expanded-tags {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result chips
|
|
||||||
.result-pass {
|
|
||||||
background-color: #e8f5e8 !important;
|
|
||||||
color: #2e7d32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-fail {
|
|
||||||
background-color: #ffebee !important;
|
|
||||||
color: #d32f2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-skip {
|
|
||||||
background-color: #fff8e1 !important;
|
|
||||||
color: #f57c00 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-error {
|
|
||||||
background-color: #fce4ec !important;
|
|
||||||
color: #c2185b !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No data state
|
|
||||||
.no-data {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
.pagination-card {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detail modal
|
|
||||||
.detail-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-modal {
|
|
||||||
max-width: 600px;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive design
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.toolbar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-buttons {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-chip {
|
|
||||||
margin-left: 0;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifacts-table {
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
.mat-mdc-cell,
|
|
||||||
.mat-mdc-header-cell {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.binaries-cell,
|
|
||||||
.tags-cell {
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override Material styles
|
|
||||||
:host ::ng-deep {
|
|
||||||
.mat-mdc-table {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-chip {
|
|
||||||
--mdc-chip-container-height: 24px;
|
|
||||||
--mdc-chip-with-avatar-container-height: 28px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-chip-set {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
import { Component, OnInit, Input, OnChanges, SimpleChanges } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatTableModule } from '@angular/material/table';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { Artifact } from '../../models/artifact.interface';
|
|
||||||
import { ArtifactService } from '../../services/artifact.service';
|
|
||||||
import { NotificationService } from '../../services/notification.service';
|
|
||||||
import { TagManagerComponent } from '../tag-manager/tag-manager.component';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-artifacts-table',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatTableModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatChipsModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatProgressSpinnerModule,
|
|
||||||
MatTooltipModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatSnackBarModule,
|
|
||||||
TagManagerComponent
|
|
||||||
],
|
|
||||||
templateUrl: './artifacts-table.component.html',
|
|
||||||
styleUrl: './artifacts-table.component.scss'
|
|
||||||
})
|
|
||||||
export class ArtifactsTableComponent implements OnInit, OnChanges {
|
|
||||||
@Input() artifacts: Artifact[] = [];
|
|
||||||
@Input() filters: any = {};
|
|
||||||
|
|
||||||
displayedColumns: string[] = [
|
|
||||||
'id',
|
|
||||||
'eventId',
|
|
||||||
'filename',
|
|
||||||
'type',
|
|
||||||
'size',
|
|
||||||
'binaries',
|
|
||||||
'testName',
|
|
||||||
'suite',
|
|
||||||
'result',
|
|
||||||
'tags',
|
|
||||||
'created',
|
|
||||||
'actions'
|
|
||||||
];
|
|
||||||
|
|
||||||
expandedBinaries: { [key: number]: boolean } = {};
|
|
||||||
expandedTags: { [key: number]: boolean } = {};
|
|
||||||
|
|
||||||
currentPage = 1;
|
|
||||||
pageSize = 25;
|
|
||||||
loading = false; // Start with false to show content immediately
|
|
||||||
|
|
||||||
selectedArtifact: Artifact | null = null;
|
|
||||||
showDetailModal = false;
|
|
||||||
|
|
||||||
filteredArtifacts: Artifact[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private artifactService: ArtifactService,
|
|
||||||
private notificationService: NotificationService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
console.log('ArtifactsTableComponent ngOnInit - artifacts count:', this.artifacts.length);
|
|
||||||
console.log('Initial loading state:', this.loading);
|
|
||||||
// Always load artifacts on init
|
|
||||||
this.loadArtifacts();
|
|
||||||
// Force show after a delay to debug
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('Timeout - forcing loading to false');
|
|
||||||
this.loading = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
console.log('ArtifactsTableComponent ngOnChanges - artifacts:', changes['artifacts']?.currentValue?.length || 0);
|
|
||||||
// Re-apply filters when artifacts or filters input changes
|
|
||||||
if (changes['artifacts'] || changes['filters']) {
|
|
||||||
this.applyFilters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadArtifacts(): void {
|
|
||||||
console.log('Loading artifacts...');
|
|
||||||
this.loading = true;
|
|
||||||
this.artifactService.getArtifacts(this.pageSize, (this.currentPage - 1) * this.pageSize)
|
|
||||||
.subscribe({
|
|
||||||
next: (artifacts) => {
|
|
||||||
console.log('Loaded artifacts:', artifacts.length);
|
|
||||||
this.artifacts = artifacts;
|
|
||||||
this.applyFilters();
|
|
||||||
this.loading = false;
|
|
||||||
console.log('Loading complete. loading =', this.loading);
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error loading artifacts:', error);
|
|
||||||
this.loading = false;
|
|
||||||
console.log('Error occurred. loading =', this.loading);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFilters(): void {
|
|
||||||
console.log('Applying filters to', this.artifacts.length, 'artifacts');
|
|
||||||
this.filteredArtifacts = this.artifacts.filter(artifact => {
|
|
||||||
if (this.filters.filename && !artifact.filename.toLowerCase().includes(this.filters.filename.toLowerCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.filters.fileType && artifact.file_type !== this.filters.fileType) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.filters.testName && !artifact.test_name?.toLowerCase().includes(this.filters.testName.toLowerCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.filters.testSuite && !artifact.test_suite?.toLowerCase().includes(this.filters.testSuite.toLowerCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.filters.testResult && artifact.test_result !== this.filters.testResult) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.filters.tags && this.filters.tags.length > 0) {
|
|
||||||
const hasMatchingTag = this.filters.tags.some((tag: string) =>
|
|
||||||
artifact.tags.some(artifactTag => artifactTag.toLowerCase().includes(tag.toLowerCase()))
|
|
||||||
);
|
|
||||||
if (!hasMatchingTag) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
console.log('Filtered artifacts count:', this.filteredArtifacts.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBinariesExpansion(artifactId: number): void {
|
|
||||||
this.expandedBinaries[artifactId] = !this.expandedBinaries[artifactId];
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTagsExpansion(artifactId: number): void {
|
|
||||||
this.expandedTags[artifactId] = !this.expandedTags[artifactId];
|
|
||||||
}
|
|
||||||
|
|
||||||
showDetail(artifact: Artifact): void {
|
|
||||||
this.selectedArtifact = artifact;
|
|
||||||
this.showDetailModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDetailModal(): void {
|
|
||||||
this.showDetailModal = false;
|
|
||||||
this.selectedArtifact = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadArtifact(artifact: Artifact): void {
|
|
||||||
this.artifactService.downloadArtifact(artifact.id).subscribe({
|
|
||||||
next: (blob) => {
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = artifact.filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error downloading artifact:', error);
|
|
||||||
this.notificationService.showError('Error downloading artifact: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteArtifact(artifact: Artifact): Promise<void> {
|
|
||||||
const confirmed = await this.notificationService.showConfirmation(
|
|
||||||
`Are you sure you want to delete "${artifact.filename}"? This cannot be undone.`,
|
|
||||||
'Delete'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.artifactService.deleteArtifact(artifact.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.notificationService.showSuccess('Artifact deleted successfully');
|
|
||||||
this.loadArtifacts();
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error deleting artifact:', error);
|
|
||||||
this.notificationService.showError('Error deleting artifact: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
generateSeedData(): void {
|
|
||||||
const count = prompt('How many artifacts to generate? (1-100)', '10');
|
|
||||||
if (!count) return;
|
|
||||||
|
|
||||||
const num = parseInt(count);
|
|
||||||
if (isNaN(num) || num < 1 || num > 100) {
|
|
||||||
this.notificationService.showWarning('Please enter a number between 1 and 100');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.artifactService.generateSeedData(num).subscribe({
|
|
||||||
next: (result) => {
|
|
||||||
this.notificationService.showSuccess(result.message || 'Seed data generated successfully');
|
|
||||||
this.loadArtifacts();
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error generating seed data:', error);
|
|
||||||
this.notificationService.showError('Error generating seed data: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
getVisibleBinaries(binaries: string[] | undefined): string[] {
|
|
||||||
if (!binaries) return [];
|
|
||||||
return binaries.slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
getHiddenBinariesCount(binaries: string[] | undefined): number {
|
|
||||||
if (!binaries) return 0;
|
|
||||||
return Math.max(0, binaries.length - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
getVisibleTags(tags: string[]): string[] {
|
|
||||||
return tags.slice(0, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
getHiddenTagsCount(tags: string[]): number {
|
|
||||||
return Math.max(0, tags.length - 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
previousPage(): void {
|
|
||||||
if (this.currentPage > 1) {
|
|
||||||
this.currentPage--;
|
|
||||||
this.loadArtifacts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextPage(): void {
|
|
||||||
this.currentPage++;
|
|
||||||
this.loadArtifacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
onTagsUpdated(): void {
|
|
||||||
this.loadArtifacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
getResultIcon(result: string): string {
|
|
||||||
switch (result) {
|
|
||||||
case 'pass': return 'check_circle';
|
|
||||||
case 'fail': return 'cancel';
|
|
||||||
case 'skip': return 'skip_next';
|
|
||||||
case 'error': return 'error';
|
|
||||||
default: return 'help';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
276
frontend/src/app/components/nav-sidebar/nav-sidebar.css
Normal file
276
frontend/src/app/components/nav-sidebar/nav-sidebar.css
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
width: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Button */
|
||||||
|
.toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: -18px;
|
||||||
|
top: 20px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b82f6;
|
||||||
|
border: 2px solid #0f172a;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.5);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 1001;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn .material-icons {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Section */
|
||||||
|
.user-section {
|
||||||
|
padding: 24px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .user-section {
|
||||||
|
padding: 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .user-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Items */
|
||||||
|
.nav-items {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-items::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-items::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-items::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: #3b82f6;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.collapsed) {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 60px;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
left: auto;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label,
|
||||||
|
.user-info {
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
40
frontend/src/app/components/nav-sidebar/nav-sidebar.html
Normal file
40
frontend/src/app/components/nav-sidebar/nav-sidebar.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<aside class="sidebar" [class.collapsed]="isCollapsed">
|
||||||
|
<!-- Toggle Button -->
|
||||||
|
<button class="toggle-btn" (click)="toggleSidebar()" aria-label="Toggle sidebar">
|
||||||
|
<span class="material-icons">{{ isCollapsed ? 'chevron_right' : 'chevron_left' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User Profile Section -->
|
||||||
|
<div class="user-section">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<span class="avatar-text">{{ user.avatar }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-info" *ngIf="!isCollapsed">
|
||||||
|
<div class="user-name">{{ user.name }}</div>
|
||||||
|
<div class="user-email">{{ user.email }}</div>
|
||||||
|
<div class="user-role">{{ user.role }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Items -->
|
||||||
|
<nav class="nav-items">
|
||||||
|
<a
|
||||||
|
*ngFor="let item of navItems"
|
||||||
|
[routerLink]="item.route"
|
||||||
|
routerLinkActive="active"
|
||||||
|
class="nav-item"
|
||||||
|
[attr.aria-label]="item.label"
|
||||||
|
>
|
||||||
|
<span class="material-icons nav-icon">{{ item.icon }}</span>
|
||||||
|
<span class="nav-label" *ngIf="!isCollapsed">{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- App Info Footer -->
|
||||||
|
<div class="sidebar-footer" *ngIf="!isCollapsed">
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="app-name">Warehouse13</div>
|
||||||
|
<div class="app-version">v1.0.0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
43
frontend/src/app/components/nav-sidebar/nav-sidebar.ts
Normal file
43
frontend/src/app/components/nav-sidebar/nav-sidebar.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Component, Output, EventEmitter } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
route: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nav-sidebar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, RouterLinkActive],
|
||||||
|
templateUrl: './nav-sidebar.html',
|
||||||
|
styleUrls: ['./nav-sidebar.css']
|
||||||
|
})
|
||||||
|
export class NavSidebarComponent {
|
||||||
|
isCollapsed = false;
|
||||||
|
|
||||||
|
@Output() sidebarToggled = new EventEmitter<boolean>();
|
||||||
|
|
||||||
|
// Hardcoded user data for now (will be replaced with OAuth)
|
||||||
|
user = {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john.doe@warehouse13.com',
|
||||||
|
avatar: 'JD',
|
||||||
|
role: 'Administrator'
|
||||||
|
};
|
||||||
|
|
||||||
|
navItems: NavItem[] = [
|
||||||
|
{ route: '/artifacts', label: 'Artifacts', icon: 'inventory_2' },
|
||||||
|
{ route: '/upload', label: 'Upload', icon: 'cloud_upload' },
|
||||||
|
{ route: '/query', label: 'Query', icon: 'search' },
|
||||||
|
{ route: '/profile', label: 'Profile', icon: 'person' },
|
||||||
|
{ route: '/settings', label: 'Settings', icon: 'settings' }
|
||||||
|
];
|
||||||
|
|
||||||
|
toggleSidebar() {
|
||||||
|
this.isCollapsed = !this.isCollapsed;
|
||||||
|
this.sidebarToggled.emit(this.isCollapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
395
frontend/src/app/components/profile/profile.css
Normal file
395
frontend/src/app/components/profile/profile.css
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
.profile-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 .material-icons {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Card */
|
||||||
|
.profile-card {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-large {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 24px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-role {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department .material-icons {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
background: transparent;
|
||||||
|
color: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item > .material-icons {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item span {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity Section */
|
||||||
|
.activity-section,
|
||||||
|
.security-section {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-section h2,
|
||||||
|
.security-section h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-section h2 .material-icons,
|
||||||
|
.security-section h2 .material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon .material-icons {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-action {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-file {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security Section */
|
||||||
|
.security-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info > .material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.profile-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-large {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
frontend/src/app/components/profile/profile.html
Normal file
128
frontend/src/app/components/profile/profile.html
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<div class="profile-container">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>
|
||||||
|
<span class="material-icons">person</span>
|
||||||
|
Profile
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">Manage your account information</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="profile-content">
|
||||||
|
<!-- User Info Card -->
|
||||||
|
<section class="profile-card">
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar-large">
|
||||||
|
<span class="avatar-text">{{ user.avatar }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info">
|
||||||
|
<h2>{{ user.name }}</h2>
|
||||||
|
<p class="email">{{ user.email }}</p>
|
||||||
|
<div class="profile-meta">
|
||||||
|
<span class="badge badge-role">{{ user.role }}</span>
|
||||||
|
<span class="department">
|
||||||
|
<span class="material-icons">business</span>
|
||||||
|
{{ user.department }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-edit">
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="material-icons">event</span>
|
||||||
|
<div>
|
||||||
|
<label>Joined</label>
|
||||||
|
<span>{{ user.joinDate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="material-icons">schedule</span>
|
||||||
|
<div>
|
||||||
|
<label>Last Login</label>
|
||||||
|
<span>{{ user.lastLogin }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<section class="stats-grid">
|
||||||
|
<div class="stat-card" *ngFor="let stat of stats">
|
||||||
|
<span class="material-icons stat-icon">{{ stat.icon }}</span>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ stat.value }}</div>
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<section class="activity-section">
|
||||||
|
<h2>
|
||||||
|
<span class="material-icons">history</span>
|
||||||
|
Recent Activity
|
||||||
|
</h2>
|
||||||
|
<div class="activity-list">
|
||||||
|
<div class="activity-item" *ngFor="let activity of recentActivity">
|
||||||
|
<div class="activity-icon">
|
||||||
|
<span class="material-icons">{{
|
||||||
|
activity.action.includes('Uploaded') ? 'cloud_upload' :
|
||||||
|
activity.action.includes('query') ? 'search' :
|
||||||
|
activity.action.includes('Downloaded') ? 'cloud_download' :
|
||||||
|
'settings'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-details">
|
||||||
|
<div class="activity-action">{{ activity.action }}</div>
|
||||||
|
<div class="activity-file">{{ activity.file }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-time">{{ activity.time }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Security Section -->
|
||||||
|
<section class="security-section">
|
||||||
|
<h2>
|
||||||
|
<span class="material-icons">security</span>
|
||||||
|
Security
|
||||||
|
</h2>
|
||||||
|
<div class="security-content">
|
||||||
|
<div class="security-item">
|
||||||
|
<div class="security-info">
|
||||||
|
<span class="material-icons">lock</span>
|
||||||
|
<div>
|
||||||
|
<h3>Password</h3>
|
||||||
|
<p>Last changed 3 months ago</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary">Change Password</button>
|
||||||
|
</div>
|
||||||
|
<div class="security-item">
|
||||||
|
<div class="security-info">
|
||||||
|
<span class="material-icons">verified_user</span>
|
||||||
|
<div>
|
||||||
|
<h3>Two-Factor Authentication</h3>
|
||||||
|
<p>Add an extra layer of security</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary">Enable 2FA</button>
|
||||||
|
</div>
|
||||||
|
<div class="security-item">
|
||||||
|
<div class="security-info">
|
||||||
|
<span class="material-icons">devices</span>
|
||||||
|
<div>
|
||||||
|
<h3>Active Sessions</h3>
|
||||||
|
<p>Manage your active login sessions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary">View Sessions</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
36
frontend/src/app/components/profile/profile.ts
Normal file
36
frontend/src/app/components/profile/profile.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './profile.html',
|
||||||
|
styleUrls: ['./profile.css']
|
||||||
|
})
|
||||||
|
export class ProfileComponent {
|
||||||
|
// Hardcoded user data (will be replaced with OAuth integration)
|
||||||
|
user = {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john.doe@warehouse13.com',
|
||||||
|
avatar: 'JD',
|
||||||
|
role: 'Administrator',
|
||||||
|
department: 'Engineering',
|
||||||
|
joinDate: 'January 15, 2024',
|
||||||
|
lastLogin: 'October 16, 2025, 2:30 PM'
|
||||||
|
};
|
||||||
|
|
||||||
|
stats = [
|
||||||
|
{ label: 'Artifacts Uploaded', value: '1,234', icon: 'cloud_upload' },
|
||||||
|
{ label: 'Queries Run', value: '567', icon: 'search' },
|
||||||
|
{ label: 'Storage Used', value: '45.2 GB', icon: 'storage' },
|
||||||
|
{ label: 'Active Since', value: '9 months', icon: 'schedule' }
|
||||||
|
];
|
||||||
|
|
||||||
|
recentActivity = [
|
||||||
|
{ action: 'Uploaded artifact', file: 'test_results.csv', time: '2 hours ago' },
|
||||||
|
{ action: 'Ran query', file: 'integration tests', time: '5 hours ago' },
|
||||||
|
{ action: 'Downloaded artifact', file: 'performance_metrics.json', time: '1 day ago' },
|
||||||
|
{ action: 'Updated settings', file: 'notification preferences', time: '2 days ago' }
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
<mat-card class="query-card">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>
|
|
||||||
<mat-icon>search</mat-icon>
|
|
||||||
Query Artifacts
|
|
||||||
</mat-card-title>
|
|
||||||
<mat-card-subtitle>
|
|
||||||
Search and filter your artifact collection
|
|
||||||
</mat-card-subtitle>
|
|
||||||
</mat-card-header>
|
|
||||||
|
|
||||||
<mat-card-content>
|
|
||||||
<form (ngSubmit)="queryArtifacts()" #queryFormRef="ngForm" class="query-form">
|
|
||||||
<!-- Basic Search Section -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Basic Search</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Filename</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
name="filename"
|
|
||||||
[(ngModel)]="queryForm.filename"
|
|
||||||
(input)="onFilterChange()"
|
|
||||||
placeholder="Search filename...">
|
|
||||||
<mat-icon matSuffix>description</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>File Type</mat-label>
|
|
||||||
<mat-select
|
|
||||||
name="file_type"
|
|
||||||
[(ngModel)]="queryForm.file_type"
|
|
||||||
(selectionChange)="onFilterChange()">
|
|
||||||
<mat-option value="">All Types</mat-option>
|
|
||||||
<mat-option *ngFor="let type of fileTypes" [value]="type">
|
|
||||||
{{ type.toUpperCase() }}
|
|
||||||
</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
<mat-icon matSuffix>category</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Test Information Section -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Test Information</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Test Name</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
name="test_name"
|
|
||||||
[(ngModel)]="queryForm.test_name"
|
|
||||||
(input)="onFilterChange()"
|
|
||||||
placeholder="Search test name...">
|
|
||||||
<mat-icon matSuffix>quiz</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Test Suite</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
name="test_suite"
|
|
||||||
[(ngModel)]="queryForm.test_suite"
|
|
||||||
(input)="onFilterChange()"
|
|
||||||
placeholder="e.g., integration">
|
|
||||||
<mat-icon matSuffix>folder</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Test Result</mat-label>
|
|
||||||
<mat-select
|
|
||||||
name="test_result"
|
|
||||||
[(ngModel)]="queryForm.test_result"
|
|
||||||
(selectionChange)="onFilterChange()">
|
|
||||||
<mat-option value="">All Results</mat-option>
|
|
||||||
<mat-option *ngFor="let result of testResults" [value]="result">
|
|
||||||
<mat-icon>{{ getResultIcon(result) }}</mat-icon>
|
|
||||||
{{ result | titlecase }}
|
|
||||||
</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
<mat-icon matSuffix>assignment_turned_in</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Tags</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
name="tags"
|
|
||||||
[(ngModel)]="tagsInput"
|
|
||||||
(input)="onFilterChange()"
|
|
||||||
placeholder="e.g., regression, smoke">
|
|
||||||
<mat-icon matSuffix>label</mat-icon>
|
|
||||||
<mat-hint>Comma-separated tags</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Range Section -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Date Range</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Start Date</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[matDatepicker]="startPicker"
|
|
||||||
name="start_date"
|
|
||||||
[(ngModel)]="queryForm.start_date">
|
|
||||||
<mat-datepicker-toggle matSuffix [for]="startPicker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #startPicker></mat-datepicker>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>End Date</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[matDatepicker]="endPicker"
|
|
||||||
name="end_date"
|
|
||||||
[(ngModel)]="queryForm.end_date">
|
|
||||||
<mat-datepicker-toggle matSuffix [for]="endPicker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #endPicker></mat-datepicker>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Progress -->
|
|
||||||
<div class="search-progress" *ngIf="searching">
|
|
||||||
<mat-spinner diameter="24"></mat-spinner>
|
|
||||||
<span>Searching artifacts...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="form-actions">
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
[disabled]="searching"
|
|
||||||
class="search-button">
|
|
||||||
<mat-icon>{{ searching ? 'hourglass_empty' : 'search' }}</mat-icon>
|
|
||||||
{{ searching ? 'Searching...' : 'Search Artifacts' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
mat-button
|
|
||||||
type="button"
|
|
||||||
(click)="clearQuery()"
|
|
||||||
[disabled]="searching">
|
|
||||||
<mat-icon>clear</mat-icon>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- Active Filters Display -->
|
|
||||||
<mat-card class="filters-card" *ngIf="queryForm.filename || queryForm.file_type || queryForm.test_name || queryForm.test_suite || queryForm.test_result || tagsInput">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>
|
|
||||||
<mat-icon>filter_list</mat-icon>
|
|
||||||
Active Filters
|
|
||||||
</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
|
|
||||||
<mat-card-content>
|
|
||||||
<mat-chip-set class="filter-chips">
|
|
||||||
<mat-chip *ngIf="queryForm.filename" color="primary">
|
|
||||||
<mat-icon matChipAvatar>description</mat-icon>
|
|
||||||
Filename: {{ queryForm.filename }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip *ngIf="queryForm.file_type" color="accent">
|
|
||||||
<mat-icon matChipAvatar>category</mat-icon>
|
|
||||||
Type: {{ queryForm.file_type }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip *ngIf="queryForm.test_name" color="primary">
|
|
||||||
<mat-icon matChipAvatar>quiz</mat-icon>
|
|
||||||
Test: {{ queryForm.test_name }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip *ngIf="queryForm.test_suite" color="accent">
|
|
||||||
<mat-icon matChipAvatar>folder</mat-icon>
|
|
||||||
Suite: {{ queryForm.test_suite }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip *ngIf="queryForm.test_result" color="primary">
|
|
||||||
<mat-icon matChipAvatar>assignment_turned_in</mat-icon>
|
|
||||||
Result: {{ queryForm.test_result }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip *ngIf="tagsInput" color="accent">
|
|
||||||
<mat-icon matChipAvatar>label</mat-icon>
|
|
||||||
Tags: {{ tagsInput }}
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
.query-card {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-card {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section {
|
|
||||||
padding: 16px 0;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #424242;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
width: 4px;
|
|
||||||
height: 20px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-progress {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
mat-spinner {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
|
|
||||||
.search-button {
|
|
||||||
padding: 12px 32px;
|
|
||||||
font-size: 16px;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
mat-chip {
|
|
||||||
--mdc-chip-container-height: 36px;
|
|
||||||
|
|
||||||
mat-icon[matChipAvatar] {
|
|
||||||
background-color: transparent !important;
|
|
||||||
color: currentColor !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Material Design overrides
|
|
||||||
:host ::ng-deep {
|
|
||||||
.mat-mdc-form-field {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.mat-mdc-form-field-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-select-panel {
|
|
||||||
max-height: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-chip {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-raised-button {
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-datepicker-toggle {
|
|
||||||
.mat-icon-button {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
.mat-mdc-button-touch-target {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card title styling
|
|
||||||
:host ::ng-deep .mat-mdc-card-header {
|
|
||||||
.mat-mdc-card-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-card-subtitle {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive design
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.query-card,
|
|
||||||
.filters-card {
|
|
||||||
margin: 0 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
.search-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chips {
|
|
||||||
gap: 6px;
|
|
||||||
|
|
||||||
mat-chip {
|
|
||||||
--mdc-chip-container-height: 32px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.query-card,
|
|
||||||
.filters-card {
|
|
||||||
margin: 0 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { Component, Output, EventEmitter } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
|
||||||
import { MatNativeDateModule } from '@angular/material/core';
|
|
||||||
import { ArtifactQuery, Artifact } from '../../models/artifact.interface';
|
|
||||||
import { ArtifactService } from '../../services/artifact.service';
|
|
||||||
import { NotificationService } from '../../services/notification.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-query-form',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatSelectModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatChipsModule,
|
|
||||||
MatProgressSpinnerModule,
|
|
||||||
MatDatepickerModule,
|
|
||||||
MatNativeDateModule
|
|
||||||
],
|
|
||||||
templateUrl: './query-form.component.html',
|
|
||||||
styleUrl: './query-form.component.scss'
|
|
||||||
})
|
|
||||||
export class QueryFormComponent {
|
|
||||||
@Output() queryResults = new EventEmitter<Artifact[]>();
|
|
||||||
@Output() filtersChange = new EventEmitter<any>();
|
|
||||||
|
|
||||||
queryForm: ArtifactQuery = {
|
|
||||||
filename: '',
|
|
||||||
file_type: '',
|
|
||||||
test_name: '',
|
|
||||||
test_suite: '',
|
|
||||||
test_result: '',
|
|
||||||
tags: [],
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
limit: 100,
|
|
||||||
offset: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
searching = false;
|
|
||||||
fileTypes = ['csv', 'json', 'binary', 'pcap'];
|
|
||||||
testResults = ['pass', 'fail', 'skip', 'error'];
|
|
||||||
|
|
||||||
tagsInput = '';
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private artifactService: ArtifactService,
|
|
||||||
private notificationService: NotificationService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
queryArtifacts(): void {
|
|
||||||
this.searching = true;
|
|
||||||
|
|
||||||
const query: ArtifactQuery = { ...this.queryForm };
|
|
||||||
|
|
||||||
if (this.tagsInput) {
|
|
||||||
query.tags = this.tagsInput.split(',').map(t => t.trim()).filter(t => t);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.start_date) {
|
|
||||||
query.start_date = new Date(query.start_date).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.end_date) {
|
|
||||||
query.end_date = new Date(query.end_date).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.artifactService.queryArtifacts(query).subscribe({
|
|
||||||
next: (artifacts) => {
|
|
||||||
this.queryResults.emit(artifacts);
|
|
||||||
this.searching = false;
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Query failed:', error);
|
|
||||||
this.notificationService.showError('Query failed: ' + error.message);
|
|
||||||
this.searching = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
clearQuery(): void {
|
|
||||||
this.queryForm = {
|
|
||||||
filename: '',
|
|
||||||
file_type: '',
|
|
||||||
test_name: '',
|
|
||||||
test_suite: '',
|
|
||||||
test_result: '',
|
|
||||||
tags: [],
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
limit: 100,
|
|
||||||
offset: 0
|
|
||||||
};
|
|
||||||
this.tagsInput = '';
|
|
||||||
this.emitFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
emitFilters(): void {
|
|
||||||
const filters = {
|
|
||||||
filename: this.queryForm.filename,
|
|
||||||
fileType: this.queryForm.file_type,
|
|
||||||
testName: this.queryForm.test_name,
|
|
||||||
testSuite: this.queryForm.test_suite,
|
|
||||||
testResult: this.queryForm.test_result,
|
|
||||||
tags: this.tagsInput ? this.tagsInput.split(',').map(t => t.trim()).filter(t => t) : []
|
|
||||||
};
|
|
||||||
this.filtersChange.emit(filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
onFilterChange(): void {
|
|
||||||
this.emitFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
getResultIcon(result: string): string {
|
|
||||||
switch (result) {
|
|
||||||
case 'pass': return 'check_circle';
|
|
||||||
case 'fail': return 'cancel';
|
|
||||||
case 'skip': return 'skip_next';
|
|
||||||
case 'error': return 'error';
|
|
||||||
default: return 'help';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
102
frontend/src/app/components/query-form/query-form.html
Normal file
102
frontend/src/app/components/query-form/query-form.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<div class="query-section">
|
||||||
|
<h2>Query Artifacts</h2>
|
||||||
|
<form [formGroup]="queryForm" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-filename">Filename</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="q-filename"
|
||||||
|
formControlName="filename"
|
||||||
|
placeholder="Search filename...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-type">File Type</label>
|
||||||
|
<select id="q-type" formControlName="file_type">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="binary">Binary</option>
|
||||||
|
<option value="pcap">PCAP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-test-name">Test Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="q-test-name"
|
||||||
|
formControlName="test_name"
|
||||||
|
placeholder="Search test name...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-suite">Test Suite</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="q-suite"
|
||||||
|
formControlName="test_suite"
|
||||||
|
placeholder="e.g., integration">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-sim-source-id">SIM Source ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="q-sim-source-id"
|
||||||
|
formControlName="sim_source_id"
|
||||||
|
placeholder="e.g., sim_run_abc123">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-result">Test Result</label>
|
||||||
|
<select id="q-result" formControlName="test_result">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="pass">Pass</option>
|
||||||
|
<option value="fail">Fail</option>
|
||||||
|
<option value="skip">Skip</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-tags">Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="q-tags"
|
||||||
|
formControlName="tags"
|
||||||
|
placeholder="e.g., regression, smoke">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-start-date">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="q-start-date"
|
||||||
|
formControlName="start_date">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="q-end-date">End Date</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="q-end-date"
|
||||||
|
formControlName="end_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-large">
|
||||||
|
<span class="material-icons md-18">search</span> Search
|
||||||
|
</button>
|
||||||
|
<button type="button" (click)="clearForm()" class="btn btn-secondary">
|
||||||
|
<span class="material-icons md-18">close</span> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
23
frontend/src/app/components/query-form/query-form.spec.ts
Normal file
23
frontend/src/app/components/query-form/query-form.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { QueryForm } from './query-form';
|
||||||
|
|
||||||
|
describe('QueryForm', () => {
|
||||||
|
let component: QueryForm;
|
||||||
|
let fixture: ComponentFixture<QueryForm>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [QueryForm]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(QueryForm);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
83
frontend/src/app/components/query-form/query-form.ts
Normal file
83
frontend/src/app/components/query-form/query-form.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Component, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { ArtifactService } from '../../services/artifact';
|
||||||
|
import { Artifact, ArtifactQuery } from '../../models/artifact.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-query-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './query-form.html',
|
||||||
|
styleUrls: ['./query-form.css']
|
||||||
|
})
|
||||||
|
export class QueryFormComponent {
|
||||||
|
queryForm: FormGroup;
|
||||||
|
@Output() resultsFound = new EventEmitter<Artifact[]>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private artifactService: ArtifactService
|
||||||
|
) {
|
||||||
|
this.queryForm = this.fb.group({
|
||||||
|
filename: [''],
|
||||||
|
file_type: [''],
|
||||||
|
test_name: [''],
|
||||||
|
test_suite: [''],
|
||||||
|
test_result: [''],
|
||||||
|
sim_source_id: [''],
|
||||||
|
tags: [''],
|
||||||
|
start_date: [''],
|
||||||
|
end_date: ['']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
const query: ArtifactQuery = {
|
||||||
|
limit: 100,
|
||||||
|
offset: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.queryForm.value.filename) {
|
||||||
|
query.filename = this.queryForm.value.filename;
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.file_type) {
|
||||||
|
query.file_type = this.queryForm.value.file_type;
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.test_name) {
|
||||||
|
query.test_name = this.queryForm.value.test_name;
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.test_suite) {
|
||||||
|
query.test_suite = this.queryForm.value.test_suite;
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.test_result) {
|
||||||
|
query.test_result = this.queryForm.value.test_result;
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.sim_source_id) {
|
||||||
|
query.sim_source_id = this.queryForm.value.sim_source_id;
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.tags) {
|
||||||
|
query.tags = this.queryForm.value.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t: string) => t.trim())
|
||||||
|
.filter((t: string) => t);
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.start_date) {
|
||||||
|
query.start_date = new Date(this.queryForm.value.start_date).toISOString();
|
||||||
|
}
|
||||||
|
if (this.queryForm.value.end_date) {
|
||||||
|
query.end_date = new Date(this.queryForm.value.end_date).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.artifactService.queryArtifacts(query).subscribe({
|
||||||
|
next: (artifacts) => {
|
||||||
|
this.resultsFound.emit(artifacts);
|
||||||
|
},
|
||||||
|
error: (err) => alert('Query failed: ' + err.message)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearForm() {
|
||||||
|
this.queryForm.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
227
frontend/src/app/components/settings/settings.css
Normal file
227
frontend/src/app/components/settings/settings.css
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
.settings-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 .material-icons {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info > .material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #334155;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Buttons */
|
||||||
|
.theme-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.endpoint {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
frontend/src/app/components/settings/settings.html
Normal file
167
frontend/src/app/components/settings/settings.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<div class="settings-container">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>
|
||||||
|
<span class="material-icons">settings</span>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">Manage your Warehouse13 preferences</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<!-- General Settings -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>General</h2>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">palette</span>
|
||||||
|
<div>
|
||||||
|
<h3>Theme</h3>
|
||||||
|
<p>Choose your preferred color scheme</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button
|
||||||
|
[class.active]="settings.theme === 'dark'"
|
||||||
|
(click)="changeTheme('dark')"
|
||||||
|
class="theme-btn">
|
||||||
|
Dark
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
[class.active]="settings.theme === 'light'"
|
||||||
|
(click)="changeTheme('light')"
|
||||||
|
class="theme-btn">
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">notifications</span>
|
||||||
|
<div>
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<p>Enable desktop notifications for updates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="settings.notifications"
|
||||||
|
(change)="toggleNotifications()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">refresh</span>
|
||||||
|
<div>
|
||||||
|
<h3>Auto Refresh</h3>
|
||||||
|
<p>Automatically refresh artifact list</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="settings.autoRefresh"
|
||||||
|
(change)="toggleAutoRefresh()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Storage Settings -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Storage</h2>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">storage</span>
|
||||||
|
<div>
|
||||||
|
<h3>Default Storage Backend</h3>
|
||||||
|
<p>Currently using: MinIO</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<span class="badge badge-success">Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">cloud</span>
|
||||||
|
<div>
|
||||||
|
<h3>Upload Limit</h3>
|
||||||
|
<p>Maximum file size: 500MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- API Settings -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>API</h2>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">link</span>
|
||||||
|
<div>
|
||||||
|
<h3>API Endpoint</h3>
|
||||||
|
<p>Backend service endpoint</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<code class="endpoint">/api/v1</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">key</span>
|
||||||
|
<div>
|
||||||
|
<h3>API Keys</h3>
|
||||||
|
<p>Manage API authentication tokens</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="btn-secondary">Manage Keys</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>About</h2>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">info</span>
|
||||||
|
<div>
|
||||||
|
<h3>Version</h3>
|
||||||
|
<p>Warehouse13 v1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="material-icons">description</span>
|
||||||
|
<div>
|
||||||
|
<h3>Documentation</h3>
|
||||||
|
<p>View API documentation and guides</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<a href="/docs" target="_blank" class="btn-secondary">
|
||||||
|
View Docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
30
frontend/src/app/components/settings/settings.ts
Normal file
30
frontend/src/app/components/settings/settings.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './settings.html',
|
||||||
|
styleUrls: ['./settings.css']
|
||||||
|
})
|
||||||
|
export class SettingsComponent {
|
||||||
|
settings = {
|
||||||
|
theme: 'dark',
|
||||||
|
notifications: true,
|
||||||
|
autoRefresh: false,
|
||||||
|
refreshInterval: 30
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleNotifications() {
|
||||||
|
this.settings.notifications = !this.settings.notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAutoRefresh() {
|
||||||
|
this.settings.autoRefresh = !this.settings.autoRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeTheme(theme: string) {
|
||||||
|
this.settings.theme = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<mat-tab-group
|
|
||||||
[(selectedIndex)]="selectedIndex"
|
|
||||||
(selectedTabChange)="onTabChange($event)"
|
|
||||||
animationDuration="300ms"
|
|
||||||
color="primary">
|
|
||||||
|
|
||||||
<!-- Artifacts Tab -->
|
|
||||||
<mat-tab>
|
|
||||||
<ng-template mat-tab-label>
|
|
||||||
<mat-icon class="tab-icon">view_list</mat-icon>
|
|
||||||
Artifacts
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
|
|
||||||
<!-- Upload Tab -->
|
|
||||||
<mat-tab>
|
|
||||||
<ng-template mat-tab-label>
|
|
||||||
<mat-icon class="tab-icon">cloud_upload</mat-icon>
|
|
||||||
Upload
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
|
|
||||||
<!-- Query Tab -->
|
|
||||||
<mat-tab>
|
|
||||||
<ng-template mat-tab-label>
|
|
||||||
<mat-icon class="tab-icon">search</mat-icon>
|
|
||||||
Query
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
</mat-tab-group>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
.tab-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep {
|
|
||||||
.mat-mdc-tab-group {
|
|
||||||
.mat-mdc-tab-header {
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-tab {
|
|
||||||
min-width: 120px;
|
|
||||||
|
|
||||||
.mat-mdc-tab-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-tab-body-wrapper {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
:host ::ng-deep {
|
|
||||||
.mat-mdc-tab {
|
|
||||||
min-width: 80px;
|
|
||||||
|
|
||||||
.mat-mdc-tab-label {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Component, EventEmitter, Output } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
|
|
||||||
export type TabType = 'artifacts' | 'upload' | 'query';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-tab-navigation',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
MatTabsModule,
|
|
||||||
MatIconModule
|
|
||||||
],
|
|
||||||
templateUrl: './tab-navigation.component.html',
|
|
||||||
styleUrl: './tab-navigation.component.scss'
|
|
||||||
})
|
|
||||||
export class TabNavigationComponent {
|
|
||||||
@Output() tabChange = new EventEmitter<TabType>();
|
|
||||||
|
|
||||||
selectedIndex = 0;
|
|
||||||
|
|
||||||
tabs = [
|
|
||||||
{ id: 'artifacts' as TabType, label: 'Artifacts', icon: 'view_list' },
|
|
||||||
{ id: 'upload' as TabType, label: 'Upload', icon: 'cloud_upload' },
|
|
||||||
{ id: 'query' as TabType, label: 'Query', icon: 'search' }
|
|
||||||
];
|
|
||||||
|
|
||||||
onTabChange(event: any): void {
|
|
||||||
const selectedTab = this.tabs[event.index];
|
|
||||||
this.tabChange.emit(selectedTab.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<div class="tag-manager">
|
|
||||||
<!-- Current Tags Display -->
|
|
||||||
<div class="current-tags-section" *ngIf="currentTags.length > 0">
|
|
||||||
<mat-chip-set>
|
|
||||||
<mat-chip
|
|
||||||
*ngFor="let tag of currentTags"
|
|
||||||
class="current-tag"
|
|
||||||
[removable]="true"
|
|
||||||
(removed)="removeTag(tag)">
|
|
||||||
<mat-icon matChipAvatar>label</mat-icon>
|
|
||||||
{{ tag }}
|
|
||||||
<mat-icon matChipRemove>cancel</mat-icon>
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Tag Button -->
|
|
||||||
<div class="add-tag-section">
|
|
||||||
<button
|
|
||||||
mat-fab
|
|
||||||
color="primary"
|
|
||||||
class="add-tag-fab"
|
|
||||||
(click)="toggleAddTag()"
|
|
||||||
[matTooltip]="showAddTag ? 'Close tag manager' : 'Add new tag'">
|
|
||||||
<mat-icon>{{ showAddTag ? 'close' : 'add' }}</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Tag Form -->
|
|
||||||
<mat-card class="add-tag-card" *ngIf="showAddTag">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>
|
|
||||||
<mat-icon>new_label</mat-icon>
|
|
||||||
Add New Tag
|
|
||||||
</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
|
|
||||||
<mat-card-content>
|
|
||||||
<form class="tag-form">
|
|
||||||
<!-- Tag Name Input -->
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Tag Name</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[(ngModel)]="newTagName"
|
|
||||||
name="tagName"
|
|
||||||
placeholder="Enter tag name"
|
|
||||||
(keyup.enter)="addTag()">
|
|
||||||
<mat-icon matSuffix>label</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Scope Section -->
|
|
||||||
<div class="scope-section">
|
|
||||||
<button
|
|
||||||
mat-button
|
|
||||||
color="accent"
|
|
||||||
type="button"
|
|
||||||
(click)="toggleScopeInput()">
|
|
||||||
<mat-icon>{{ showScopeInput ? 'expand_less' : 'expand_more' }}</mat-icon>
|
|
||||||
{{ showScopeInput ? 'Hide Scope Options' : 'Add Scope' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="scope-inputs" *ngIf="showScopeInput">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Predefined Scope</mat-label>
|
|
||||||
<mat-select [(ngModel)]="newTagScope" name="scopeSelect">
|
|
||||||
<mat-option value="">No scope</mat-option>
|
|
||||||
<mat-option *ngFor="let scope of predefinedScopes" [value]="scope">
|
|
||||||
{{ scope | titlecase }}
|
|
||||||
</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Custom Scope</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[(ngModel)]="newTagScope"
|
|
||||||
name="customScope"
|
|
||||||
placeholder="Enter custom scope">
|
|
||||||
<mat-icon matSuffix>category</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="form-actions">
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
(click)="addTag()"
|
|
||||||
[disabled]="!newTagName.trim()">
|
|
||||||
<mat-icon>add</mat-icon>
|
|
||||||
Add Tag
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
mat-button
|
|
||||||
(click)="resetForm()">
|
|
||||||
<mat-icon>clear</mat-icon>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- Available Tags (Quick Add) -->
|
|
||||||
<mat-expansion-panel class="available-tags-panel" *ngIf="showAddTag && availableTags.length > 0">
|
|
||||||
<mat-expansion-panel-header>
|
|
||||||
<mat-panel-title>
|
|
||||||
<mat-icon>library_add</mat-icon>
|
|
||||||
Quick Add Existing Tags
|
|
||||||
</mat-panel-title>
|
|
||||||
<mat-panel-description>
|
|
||||||
Click to add existing tags
|
|
||||||
</mat-panel-description>
|
|
||||||
</mat-expansion-panel-header>
|
|
||||||
|
|
||||||
<!-- Unscoped Tags -->
|
|
||||||
<div class="tag-group" *ngIf="getTagsByScope().length > 0">
|
|
||||||
<h4>General Tags</h4>
|
|
||||||
<mat-chip-set class="available-chip-set">
|
|
||||||
<mat-chip
|
|
||||||
*ngFor="let tag of getTagsByScope()"
|
|
||||||
class="available-tag"
|
|
||||||
[class.attached]="isTagAttached(tag.name)"
|
|
||||||
[disabled]="isTagAttached(tag.name)"
|
|
||||||
(click)="!isTagAttached(tag.name) && addExistingTag(tag)">
|
|
||||||
{{ tag.name }}
|
|
||||||
<mat-icon *ngIf="isTagAttached(tag.name)" matChipTrailingIcon>check</mat-icon>
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scoped Tags -->
|
|
||||||
<div class="tag-group" *ngFor="let scope of getUniqueScopes()">
|
|
||||||
<h4>{{ scope | titlecase }} Tags</h4>
|
|
||||||
<mat-chip-set class="available-chip-set">
|
|
||||||
<mat-chip
|
|
||||||
*ngFor="let tag of getTagsByScope(scope)"
|
|
||||||
class="available-tag scoped"
|
|
||||||
[class.attached]="isTagAttached(tag.name)"
|
|
||||||
[disabled]="isTagAttached(tag.name)"
|
|
||||||
(click)="!isTagAttached(tag.name) && addExistingTag(tag)">
|
|
||||||
{{ tag.name }}
|
|
||||||
<mat-icon *ngIf="isTagAttached(tag.name)" matChipTrailingIcon>check</mat-icon>
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</mat-expansion-panel>
|
|
||||||
</div>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
.tag-manager {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-tags-section {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
mat-chip-set {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-tag {
|
|
||||||
background-color: #e3f2fd !important;
|
|
||||||
color: #1976d2 !important;
|
|
||||||
|
|
||||||
mat-icon[matChipAvatar] {
|
|
||||||
background-color: #1976d2 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-section {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 16px 0;
|
|
||||||
|
|
||||||
.add-tag-fab {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-card {
|
|
||||||
max-width: 500px;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
mat-card-header {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
mat-card-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
.full-width {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.scope-inputs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-tags-panel {
|
|
||||||
margin-top: 16px;
|
|
||||||
|
|
||||||
.tag-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #424242;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
width: 4px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: #2196f3;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-chip-set {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-tag {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:not(.attached):hover {
|
|
||||||
background-color: #e8f5e8 !important;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.attached {
|
|
||||||
background-color: #c8e6c9 !important;
|
|
||||||
color: #2e7d32 !important;
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.scoped {
|
|
||||||
border-left: 3px solid #ff9800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Material Design overrides
|
|
||||||
:host ::ng-deep {
|
|
||||||
.mat-mdc-chip {
|
|
||||||
--mdc-chip-container-height: 32px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
&.current-tag {
|
|
||||||
--mdc-chip-with-avatar-container-height: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-chip-set {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-fab {
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 6px 12px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-expansion-panel-header {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-expansion-panel-body {
|
|
||||||
padding: 16px 24px 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive design
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tag-manager {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-card {
|
|
||||||
margin: 0 8px;
|
|
||||||
|
|
||||||
.tag-form .scope-inputs {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-group {
|
|
||||||
.available-chip-set {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.add-tag-card .tag-form .form-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
||||||
import { MatExpansionModule } from '@angular/material/expansion';
|
|
||||||
import { Tag } from '../../models/artifact.interface';
|
|
||||||
import { ArtifactService } from '../../services/artifact.service';
|
|
||||||
import { NotificationService } from '../../services/notification.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-tag-manager',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatChipsModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatSelectModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatTooltipModule,
|
|
||||||
MatExpansionModule
|
|
||||||
],
|
|
||||||
templateUrl: './tag-manager.component.html',
|
|
||||||
styleUrl: './tag-manager.component.scss'
|
|
||||||
})
|
|
||||||
export class TagManagerComponent implements OnInit {
|
|
||||||
@Input() artifactId!: number;
|
|
||||||
@Input() currentTags: string[] = [];
|
|
||||||
@Output() tagsUpdated = new EventEmitter<void>();
|
|
||||||
|
|
||||||
availableTags: Tag[] = [];
|
|
||||||
newTagName = '';
|
|
||||||
newTagScope = '';
|
|
||||||
showAddTag = false;
|
|
||||||
showScopeInput = false;
|
|
||||||
|
|
||||||
predefinedScopes = ['project', 'environment', 'priority', 'category', 'status'];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private artifactService: ArtifactService,
|
|
||||||
private notificationService: NotificationService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadAvailableTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAvailableTags(): void {
|
|
||||||
this.artifactService.getAllTags().subscribe({
|
|
||||||
next: (tags) => {
|
|
||||||
this.availableTags = tags;
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
// Tags endpoint not implemented yet - silently ignore
|
|
||||||
if (error.status === 404) {
|
|
||||||
console.log('Tags API not implemented yet');
|
|
||||||
this.availableTags = [];
|
|
||||||
} else {
|
|
||||||
console.error('Error loading tags:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAddTag(): void {
|
|
||||||
this.showAddTag = !this.showAddTag;
|
|
||||||
if (!this.showAddTag) {
|
|
||||||
this.resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleScopeInput(): void {
|
|
||||||
this.showScopeInput = !this.showScopeInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm(): void {
|
|
||||||
this.newTagName = '';
|
|
||||||
this.newTagScope = '';
|
|
||||||
this.showScopeInput = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag(): void {
|
|
||||||
if (!this.newTagName.trim()) return;
|
|
||||||
|
|
||||||
const tag: Tag = {
|
|
||||||
name: this.newTagName.trim(),
|
|
||||||
scope: this.newTagScope.trim() || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.artifactService.createTag(tag).subscribe({
|
|
||||||
next: (createdTag) => {
|
|
||||||
this.artifactService.addTag(this.artifactId, createdTag).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.loadAvailableTags();
|
|
||||||
this.tagsUpdated.emit();
|
|
||||||
this.resetForm();
|
|
||||||
this.showAddTag = false;
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error adding tag to artifact:', error);
|
|
||||||
this.notificationService.showError('Error adding tag to artifact: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error creating tag:', error);
|
|
||||||
this.notificationService.showError('Error creating tag: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(tag: string): void {
|
|
||||||
const tagToRemove = this.availableTags.find(t => t.name === tag);
|
|
||||||
if (!tagToRemove?.id) return;
|
|
||||||
|
|
||||||
this.artifactService.removeTag(this.artifactId, tagToRemove.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.tagsUpdated.emit();
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error removing tag:', error);
|
|
||||||
this.notificationService.showError('Error removing tag: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addExistingTag(tag: Tag): void {
|
|
||||||
this.artifactService.addTag(this.artifactId, tag).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.tagsUpdated.emit();
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Error adding existing tag:', error);
|
|
||||||
this.notificationService.showError('Error adding tag: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isTagAttached(tagName: string): boolean {
|
|
||||||
return this.currentTags.includes(tagName);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTagsByScope(scope?: string): Tag[] {
|
|
||||||
return this.availableTags.filter(tag => tag.scope === scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
getUniqueScopes(): string[] {
|
|
||||||
const scopes = this.availableTags
|
|
||||||
.map(tag => tag.scope)
|
|
||||||
.filter((scope, index, arr) => scope && arr.indexOf(scope) === index) as string[];
|
|
||||||
return scopes.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
getTagColor(tag: Tag): string {
|
|
||||||
if (tag.color) return tag.color;
|
|
||||||
|
|
||||||
const colors = ['#e0e7ff', '#fef3c7', '#d1fae5', '#fee2e2', '#f3e8ff', '#dbeafe'];
|
|
||||||
const hash = tag.name.split('').reduce((a, b) => {
|
|
||||||
a = ((a << 5) - a) + b.charCodeAt(0);
|
|
||||||
return a & a;
|
|
||||||
}, 0);
|
|
||||||
return colors[Math.abs(hash) % colors.length];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<mat-card class="upload-card">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>
|
|
||||||
<mat-icon>cloud_upload</mat-icon>
|
|
||||||
Upload Artifact
|
|
||||||
</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
|
|
||||||
<mat-card-content>
|
|
||||||
<form (ngSubmit)="uploadArtifact()" #uploadForm="ngForm" class="upload-form">
|
|
||||||
<!-- File Upload Section -->
|
|
||||||
<div class="file-upload-section">
|
|
||||||
<div class="file-input-container">
|
|
||||||
<input
|
|
||||||
#fileInput
|
|
||||||
type="file"
|
|
||||||
id="file"
|
|
||||||
name="file"
|
|
||||||
(change)="onFileSelected($event)"
|
|
||||||
required
|
|
||||||
style="display: none;">
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Select File</mat-label>
|
|
||||||
<input matInput
|
|
||||||
[value]="selectedFile?.name || ''"
|
|
||||||
placeholder="No file selected"
|
|
||||||
readonly>
|
|
||||||
<button mat-icon-button
|
|
||||||
matSuffix
|
|
||||||
type="button"
|
|
||||||
(click)="fileInput.click()"
|
|
||||||
[attr.aria-label]="'Select file'">
|
|
||||||
<mat-icon>folder_open</mat-icon>
|
|
||||||
</button>
|
|
||||||
<mat-hint>Supported: CSV, JSON, binary files, PCAP</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngIf="selectedFile" class="selected-file-info">
|
|
||||||
<mat-chip-set>
|
|
||||||
<mat-chip color="primary">
|
|
||||||
<mat-icon matChipAvatar>description</mat-icon>
|
|
||||||
{{ selectedFile.name }}
|
|
||||||
</mat-chip>
|
|
||||||
<mat-chip color="accent">
|
|
||||||
<mat-icon matChipAvatar>data_usage</mat-icon>
|
|
||||||
{{ selectedFile.size | number }} bytes
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Event ID and Test Name -->
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Event ID</mat-label>
|
|
||||||
<input matInput
|
|
||||||
name="eventId"
|
|
||||||
[(ngModel)]="formData.eventId"
|
|
||||||
placeholder="e.g., EVENT_001">
|
|
||||||
<mat-icon matSuffix>event</mat-icon>
|
|
||||||
<mat-hint>Groups multiple artifacts under the same event</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Test Name</mat-label>
|
|
||||||
<input matInput
|
|
||||||
name="testName"
|
|
||||||
[(ngModel)]="formData.testName"
|
|
||||||
placeholder="e.g., login_test">
|
|
||||||
<mat-icon matSuffix>quiz</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Test Suite and Result -->
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Test Suite</mat-label>
|
|
||||||
<input matInput
|
|
||||||
name="testSuite"
|
|
||||||
[(ngModel)]="formData.testSuite"
|
|
||||||
placeholder="e.g., integration">
|
|
||||||
<mat-icon matSuffix>category</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Test Result</mat-label>
|
|
||||||
<mat-select name="testResult" [(ngModel)]="formData.testResult">
|
|
||||||
<mat-option value="">-- Select --</mat-option>
|
|
||||||
<mat-option *ngFor="let result of testResults" [value]="result">
|
|
||||||
<mat-icon>{{ getResultIcon(result) }}</mat-icon>
|
|
||||||
{{ result | titlecase }}
|
|
||||||
</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
<mat-icon matSuffix>assignment_turned_in</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version and Binaries -->
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Version</mat-label>
|
|
||||||
<input matInput
|
|
||||||
name="version"
|
|
||||||
[(ngModel)]="formData.version"
|
|
||||||
placeholder="e.g., v1.0.0">
|
|
||||||
<mat-icon matSuffix>tag</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Associated Binaries</mat-label>
|
|
||||||
<input matInput
|
|
||||||
name="binaries"
|
|
||||||
[(ngModel)]="formData.binaries"
|
|
||||||
placeholder="e.g., app.exe, lib.dll, config.json">
|
|
||||||
<mat-icon matSuffix>code</mat-icon>
|
|
||||||
<mat-hint>Comma-separated list of binaries/files</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Tags</mat-label>
|
|
||||||
<input matInput
|
|
||||||
name="tags"
|
|
||||||
[(ngModel)]="formData.tags"
|
|
||||||
placeholder="e.g., regression, smoke, critical">
|
|
||||||
<mat-icon matSuffix>label</mat-icon>
|
|
||||||
<mat-hint>Comma-separated tags</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Description</mat-label>
|
|
||||||
<textarea matInput
|
|
||||||
name="description"
|
|
||||||
[(ngModel)]="formData.description"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Describe this artifact..."></textarea>
|
|
||||||
<mat-icon matSuffix>description</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- JSON Fields -->
|
|
||||||
<div class="json-section">
|
|
||||||
<h3>Advanced Configuration</h3>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Test Config (JSON)</mat-label>
|
|
||||||
<textarea matInput
|
|
||||||
name="testConfig"
|
|
||||||
[(ngModel)]="formData.testConfig"
|
|
||||||
rows="4"
|
|
||||||
placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
|
|
||||||
<mat-icon matSuffix>settings</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Custom Metadata (JSON)</mat-label>
|
|
||||||
<textarea matInput
|
|
||||||
name="customMetadata"
|
|
||||||
[(ngModel)]="formData.customMetadata"
|
|
||||||
rows="4"
|
|
||||||
placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
|
|
||||||
<mat-icon matSuffix>data_object</mat-icon>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Progress -->
|
|
||||||
<mat-progress-bar *ngIf="uploading"
|
|
||||||
mode="indeterminate"
|
|
||||||
class="upload-progress"></mat-progress-bar>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="form-actions">
|
|
||||||
<button mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
[disabled]="uploading || !selectedFile"
|
|
||||||
class="upload-button">
|
|
||||||
<mat-icon>{{ uploading ? 'hourglass_empty' : 'cloud_upload' }}</mat-icon>
|
|
||||||
{{ uploading ? 'Uploading...' : 'Upload Artifact' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Status Messages -->
|
|
||||||
<div *ngIf="uploadStatus" class="status-section">
|
|
||||||
<mat-chip-set>
|
|
||||||
<mat-chip [class]="uploadStatusType">
|
|
||||||
<mat-icon matChipAvatar>
|
|
||||||
{{ uploadStatusType === 'success' ? 'check_circle' : 'error' }}
|
|
||||||
</mat-icon>
|
|
||||||
{{ uploadStatus }}
|
|
||||||
</mat-chip>
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
.upload-card {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-upload-section {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.file-input-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-file-info {
|
|
||||||
margin-top: 12px;
|
|
||||||
|
|
||||||
mat-chip-set {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-width {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-section {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 24px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #424242;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-progress {
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-button {
|
|
||||||
padding: 12px 32px;
|
|
||||||
font-size: 16px;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-section {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
mat-chip-set {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
background-color: #c8e6c9 !important;
|
|
||||||
color: #2e7d32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background-color: #ffcdd2 !important;
|
|
||||||
color: #d32f2f !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Material Design overrides
|
|
||||||
:host ::ng-deep {
|
|
||||||
.mat-mdc-form-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-form-field-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-chip {
|
|
||||||
--mdc-chip-container-height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-text-field-wrapper {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive design
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-card {
|
|
||||||
margin: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.upload-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { Component, Output, EventEmitter } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { ArtifactService } from '../../services/artifact.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-upload-form',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatSelectModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatChipsModule,
|
|
||||||
MatProgressBarModule,
|
|
||||||
MatSnackBarModule
|
|
||||||
],
|
|
||||||
templateUrl: './upload-form.component.html',
|
|
||||||
styleUrl: './upload-form.component.scss'
|
|
||||||
})
|
|
||||||
export class UploadFormComponent {
|
|
||||||
@Output() uploadSuccess = new EventEmitter<void>();
|
|
||||||
|
|
||||||
selectedFile: File | null = null;
|
|
||||||
uploading = false;
|
|
||||||
uploadStatus = '';
|
|
||||||
uploadStatusType: 'success' | 'error' | '' = '';
|
|
||||||
|
|
||||||
formData = {
|
|
||||||
testName: '',
|
|
||||||
testSuite: '',
|
|
||||||
testResult: '',
|
|
||||||
version: '',
|
|
||||||
description: '',
|
|
||||||
tags: '',
|
|
||||||
testConfig: '',
|
|
||||||
customMetadata: '',
|
|
||||||
eventId: '',
|
|
||||||
binaries: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
testResults = ['pass', 'fail', 'skip', 'error'];
|
|
||||||
|
|
||||||
constructor(private artifactService: ArtifactService) {}
|
|
||||||
|
|
||||||
onFileSelected(event: any): void {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
this.selectedFile = file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm(): void {
|
|
||||||
this.selectedFile = null;
|
|
||||||
this.formData = {
|
|
||||||
testName: '',
|
|
||||||
testSuite: '',
|
|
||||||
testResult: '',
|
|
||||||
version: '',
|
|
||||||
description: '',
|
|
||||||
tags: '',
|
|
||||||
testConfig: '',
|
|
||||||
customMetadata: '',
|
|
||||||
eventId: '',
|
|
||||||
binaries: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileInput = document.getElementById('file') as HTMLInputElement;
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showUploadStatus(message: string, success: boolean): void {
|
|
||||||
this.uploadStatus = message;
|
|
||||||
this.uploadStatusType = success ? 'success' : 'error';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.uploadStatus = '';
|
|
||||||
this.uploadStatusType = '';
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadArtifact(): void {
|
|
||||||
if (!this.selectedFile) {
|
|
||||||
this.showUploadStatus('Please select a file to upload', false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploading = true;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', this.selectedFile);
|
|
||||||
|
|
||||||
const fields = ['testName', 'testSuite', 'testResult', 'version', 'description', 'eventId'];
|
|
||||||
fields.forEach(field => {
|
|
||||||
const key = field === 'testName' ? 'test_name' :
|
|
||||||
field === 'testSuite' ? 'test_suite' :
|
|
||||||
field === 'testResult' ? 'test_result' :
|
|
||||||
field === 'eventId' ? 'event_id' : field;
|
|
||||||
|
|
||||||
const value = this.formData[field as keyof typeof this.formData];
|
|
||||||
if (value) {
|
|
||||||
formData.append(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.formData.tags) {
|
|
||||||
const tagsArray = this.formData.tags.split(',').map(t => t.trim()).filter(t => t);
|
|
||||||
formData.append('tags', JSON.stringify(tagsArray));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.formData.binaries) {
|
|
||||||
const binariesArray = this.formData.binaries.split(',').map(b => b.trim()).filter(b => b);
|
|
||||||
formData.append('binaries', JSON.stringify(binariesArray));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.formData.testConfig) {
|
|
||||||
try {
|
|
||||||
JSON.parse(this.formData.testConfig);
|
|
||||||
formData.append('test_config', this.formData.testConfig);
|
|
||||||
} catch (e) {
|
|
||||||
this.showUploadStatus('Invalid Test Config JSON', false);
|
|
||||||
this.uploading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.formData.customMetadata) {
|
|
||||||
try {
|
|
||||||
JSON.parse(this.formData.customMetadata);
|
|
||||||
formData.append('custom_metadata', this.formData.customMetadata);
|
|
||||||
} catch (e) {
|
|
||||||
this.showUploadStatus('Invalid Custom Metadata JSON', false);
|
|
||||||
this.uploading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.artifactService.uploadArtifact(formData).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.showUploadStatus(`Successfully uploaded: ${response.filename}`, true);
|
|
||||||
this.resetForm();
|
|
||||||
this.uploadSuccess.emit();
|
|
||||||
this.uploading = false;
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
this.showUploadStatus('Upload failed: ' + (error.error?.detail || error.message), false);
|
|
||||||
this.uploading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getResultIcon(result: string): string {
|
|
||||||
switch (result) {
|
|
||||||
case 'pass': return 'check_circle';
|
|
||||||
case 'fail': return 'cancel';
|
|
||||||
case 'skip': return 'skip_next';
|
|
||||||
case 'error': return 'error';
|
|
||||||
default: return 'help';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
109
frontend/src/app/components/upload-form/upload-form.html
Normal file
109
frontend/src/app/components/upload-form/upload-form.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<div class="upload-section">
|
||||||
|
<h2>Upload Artifact</h2>
|
||||||
|
<form [formGroup]="uploadForm" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="file">File *</label>
|
||||||
|
<input type="file" id="file" (change)="onFileSelected($event)" required>
|
||||||
|
<small>Supported: CSV, JSON, binary files, PCAP</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sim-source">Sim Source *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="sim-source"
|
||||||
|
formControlName="sim_source"
|
||||||
|
placeholder="e.g., Jenkins, GitLab CI"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="uploaded-by">Uploaded By *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="uploaded-by"
|
||||||
|
formControlName="uploaded_by"
|
||||||
|
placeholder="e.g., john.doe"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sim-source-id">SIM Source ID (for grouping)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="sim-source-id"
|
||||||
|
formControlName="sim_source_id"
|
||||||
|
placeholder="e.g., sim_run_20251015_001">
|
||||||
|
<small>Use same ID for multiple artifacts from same source</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tags">Tags (comma-separated) *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tags"
|
||||||
|
formControlName="tags"
|
||||||
|
placeholder="e.g., regression, smoke, critical"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="test-result">Test Result</label>
|
||||||
|
<select id="test-result" formControlName="test_result">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
<option value="pass">Pass</option>
|
||||||
|
<option value="fail">Fail</option>
|
||||||
|
<option value="skip">Skip</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="version">Version</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="version"
|
||||||
|
formControlName="version"
|
||||||
|
placeholder="e.g., v1.0.0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
formControlName="description"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Describe this artifact..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="test-config">Test Config (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
id="test-config"
|
||||||
|
formControlName="test_config"
|
||||||
|
rows="4"
|
||||||
|
placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="custom-metadata">Custom Metadata (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
id="custom-metadata"
|
||||||
|
formControlName="custom_metadata"
|
||||||
|
rows="4"
|
||||||
|
placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large" [disabled]="uploading">
|
||||||
|
<span class="material-icons md-18">upload</span>
|
||||||
|
{{ uploading ? 'Uploading...' : 'Upload Artifact' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div *ngIf="uploadStatus" class="upload-status" [class.success]="uploadStatus.success" [class.error]="!uploadStatus.success">
|
||||||
|
{{ uploadStatus.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
23
frontend/src/app/components/upload-form/upload-form.spec.ts
Normal file
23
frontend/src/app/components/upload-form/upload-form.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { UploadForm } from './upload-form';
|
||||||
|
|
||||||
|
describe('UploadForm', () => {
|
||||||
|
let component: UploadForm;
|
||||||
|
let fixture: ComponentFixture<UploadForm>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [UploadForm]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(UploadForm);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
132
frontend/src/app/components/upload-form/upload-form.ts
Normal file
132
frontend/src/app/components/upload-form/upload-form.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { ArtifactService } from '../../services/artifact';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-upload-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './upload-form.html',
|
||||||
|
styleUrls: ['./upload-form.css']
|
||||||
|
})
|
||||||
|
export class UploadFormComponent {
|
||||||
|
uploadForm: FormGroup;
|
||||||
|
selectedFile: File | null = null;
|
||||||
|
uploading: boolean = false;
|
||||||
|
uploadStatus: { message: string, success: boolean } | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private artifactService: ArtifactService
|
||||||
|
) {
|
||||||
|
this.uploadForm = this.fb.group({
|
||||||
|
file: [null, Validators.required],
|
||||||
|
sim_source: ['', Validators.required],
|
||||||
|
uploaded_by: ['', Validators.required],
|
||||||
|
sim_source_id: [''],
|
||||||
|
tags: ['', Validators.required],
|
||||||
|
test_result: [''],
|
||||||
|
version: [''],
|
||||||
|
description: [''],
|
||||||
|
test_config: [''],
|
||||||
|
custom_metadata: ['']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
this.selectedFile = input.files[0];
|
||||||
|
this.uploadForm.patchValue({ file: this.selectedFile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (!this.uploadForm.valid || !this.selectedFile) {
|
||||||
|
this.showStatus('Please fill in all required fields and select a file', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate JSON fields
|
||||||
|
const testConfig = this.uploadForm.value.test_config;
|
||||||
|
const customMetadata = this.uploadForm.value.custom_metadata;
|
||||||
|
|
||||||
|
if (testConfig) {
|
||||||
|
try {
|
||||||
|
JSON.parse(testConfig);
|
||||||
|
} catch (e) {
|
||||||
|
this.showStatus('Invalid Test Config JSON', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customMetadata) {
|
||||||
|
try {
|
||||||
|
JSON.parse(customMetadata);
|
||||||
|
} catch (e) {
|
||||||
|
this.showStatus('Invalid Custom Metadata JSON', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', this.selectedFile);
|
||||||
|
formData.append('test_suite', this.uploadForm.value.sim_source);
|
||||||
|
formData.append('test_name', this.uploadForm.value.uploaded_by);
|
||||||
|
|
||||||
|
if (this.uploadForm.value.sim_source_id) {
|
||||||
|
formData.append('sim_source_id', this.uploadForm.value.sim_source_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and append tags as JSON array
|
||||||
|
if (this.uploadForm.value.tags) {
|
||||||
|
const tagsArray = this.uploadForm.value.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t: string) => t.trim())
|
||||||
|
.filter((t: string) => t);
|
||||||
|
formData.append('tags', JSON.stringify(tagsArray));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.uploadForm.value.test_result) {
|
||||||
|
formData.append('test_result', this.uploadForm.value.test_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.uploadForm.value.version) {
|
||||||
|
formData.append('version', this.uploadForm.value.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.uploadForm.value.description) {
|
||||||
|
formData.append('description', this.uploadForm.value.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testConfig) {
|
||||||
|
formData.append('test_config', testConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customMetadata) {
|
||||||
|
formData.append('custom_metadata', customMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploading = true;
|
||||||
|
this.artifactService.uploadArtifact(formData).subscribe({
|
||||||
|
next: (artifact) => {
|
||||||
|
this.showStatus(`Successfully uploaded: ${artifact.filename}`, true);
|
||||||
|
this.uploadForm.reset();
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.uploading = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.showStatus('Upload failed: ' + err.error?.detail || err.message, false);
|
||||||
|
this.uploading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private showStatus(message: string, success: boolean) {
|
||||||
|
this.uploadStatus = { message, success };
|
||||||
|
setTimeout(() => {
|
||||||
|
this.uploadStatus = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,19 @@ export interface Artifact {
|
|||||||
file_type: string;
|
file_type: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
storage_path: string;
|
storage_path: string;
|
||||||
test_name?: string;
|
content_type: string | null;
|
||||||
test_suite?: string;
|
test_name: string | null;
|
||||||
test_result?: 'pass' | 'fail' | 'skip' | 'error';
|
test_suite: string | null;
|
||||||
test_config?: any;
|
test_config: any;
|
||||||
custom_metadata?: any;
|
test_result: string | null;
|
||||||
description?: string;
|
sim_source_id: string | null;
|
||||||
tags: string[];
|
custom_metadata: any;
|
||||||
version?: string;
|
description: string | null;
|
||||||
|
tags: string[] | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
event_id?: string;
|
version: string | null;
|
||||||
binaries?: string[];
|
parent_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtifactQuery {
|
export interface ArtifactQuery {
|
||||||
@@ -24,6 +25,7 @@ export interface ArtifactQuery {
|
|||||||
test_name?: string;
|
test_name?: string;
|
||||||
test_suite?: string;
|
test_suite?: string;
|
||||||
test_result?: string;
|
test_result?: string;
|
||||||
|
sim_source_id?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
@@ -34,18 +36,5 @@ export interface ArtifactQuery {
|
|||||||
export interface ApiInfo {
|
export interface ApiInfo {
|
||||||
deployment_mode: string;
|
deployment_mode: string;
|
||||||
storage_backend: string;
|
storage_backend: string;
|
||||||
}
|
version: string;
|
||||||
|
|
||||||
export interface UploadResponse {
|
|
||||||
id: number;
|
|
||||||
filename: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tag {
|
|
||||||
id?: number;
|
|
||||||
name: string;
|
|
||||||
scope?: string;
|
|
||||||
color?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { ApiInfo } from '../models/artifact.interface';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class ApiService {
|
|
||||||
// Use relative URL - proxy will forward to backend
|
|
||||||
private readonly API_BASE = '';
|
|
||||||
|
|
||||||
constructor(private http: HttpClient) { }
|
|
||||||
|
|
||||||
getApiInfo(): Observable<ApiInfo> {
|
|
||||||
return this.http.get<ApiInfo>(`${this.API_BASE}/api`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { Observable, BehaviorSubject } from 'rxjs';
|
|
||||||
import { Artifact, ArtifactQuery, UploadResponse, Tag } from '../models/artifact.interface';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class ArtifactService {
|
|
||||||
// Use relative URL - proxy will forward to backend
|
|
||||||
private readonly API_BASE = '/api/v1';
|
|
||||||
private artifactsSubject = new BehaviorSubject<Artifact[]>([]);
|
|
||||||
public artifacts$ = this.artifactsSubject.asObservable();
|
|
||||||
|
|
||||||
constructor(private http: HttpClient) { }
|
|
||||||
|
|
||||||
getArtifacts(limit: number = 25, offset: number = 0): Observable<Artifact[]> {
|
|
||||||
const params = new HttpParams()
|
|
||||||
.set('limit', limit.toString())
|
|
||||||
.set('offset', offset.toString());
|
|
||||||
|
|
||||||
return this.http.get<Artifact[]>(`${this.API_BASE}/artifacts/`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
getArtifact(id: number): Observable<Artifact> {
|
|
||||||
return this.http.get<Artifact>(`${this.API_BASE}/artifacts/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadArtifact(formData: FormData): Observable<UploadResponse> {
|
|
||||||
return this.http.post<UploadResponse>(`${this.API_BASE}/artifacts/upload`, formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteArtifact(id: number): Observable<any> {
|
|
||||||
return this.http.delete(`${this.API_BASE}/artifacts/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadArtifact(id: number): Observable<Blob> {
|
|
||||||
return this.http.get(`${this.API_BASE}/artifacts/${id}/download`, { responseType: 'blob' });
|
|
||||||
}
|
|
||||||
|
|
||||||
queryArtifacts(query: ArtifactQuery): Observable<Artifact[]> {
|
|
||||||
return this.http.post<Artifact[]>(`${this.API_BASE}/artifacts/query`, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
generateSeedData(count: number): Observable<any> {
|
|
||||||
return this.http.post(`${this.API_BASE}/seed/generate/${count}`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateArtifactsCache(artifacts: Artifact[]): void {
|
|
||||||
this.artifactsSubject.next(artifacts);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentArtifacts(): Artifact[] {
|
|
||||||
return this.artifactsSubject.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag(artifactId: number, tag: Tag): Observable<any> {
|
|
||||||
return this.http.post(`${this.API_BASE}/artifacts/${artifactId}/tags`, tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(artifactId: number, tagId: number): Observable<any> {
|
|
||||||
return this.http.delete(`${this.API_BASE}/artifacts/${artifactId}/tags/${tagId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllTags(): Observable<Tag[]> {
|
|
||||||
return this.http.get<Tag[]>(`${this.API_BASE}/tags`);
|
|
||||||
}
|
|
||||||
|
|
||||||
createTag(tag: Tag): Observable<Tag> {
|
|
||||||
return this.http.post<Tag>(`${this.API_BASE}/tags`, tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
51
frontend/src/app/services/artifact.ts
Normal file
51
frontend/src/app/services/artifact.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Artifact, ArtifactQuery, ApiInfo } from '../models/artifact.model';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ArtifactService {
|
||||||
|
private apiUrl = '/api/v1/artifacts';
|
||||||
|
private baseUrl = '/api';
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
getApiInfo(): Observable<ApiInfo> {
|
||||||
|
return this.http.get<ApiInfo>(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
listArtifacts(limit: number = 100, offset: number = 0): Observable<Artifact[]> {
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('limit', limit.toString())
|
||||||
|
.set('offset', offset.toString());
|
||||||
|
return this.http.get<Artifact[]>(this.apiUrl + '/', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getArtifact(id: number): Observable<Artifact> {
|
||||||
|
return this.http.get<Artifact>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryArtifacts(query: ArtifactQuery): Observable<Artifact[]> {
|
||||||
|
return this.http.post<Artifact[]>(`${this.apiUrl}/query`, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadArtifact(formData: FormData): Observable<Artifact> {
|
||||||
|
return this.http.post<Artifact>(`${this.apiUrl}/upload`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadArtifact(id: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.apiUrl}/${id}/download`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteArtifact(id: number): Observable<any> {
|
||||||
|
return this.http.delete(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSeedData(count: number): Observable<any> {
|
||||||
|
return this.http.post(`/api/v1/seed/generate/${count}`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class NotificationService {
|
|
||||||
|
|
||||||
constructor(private snackBar: MatSnackBar) {}
|
|
||||||
|
|
||||||
showSuccess(message: string, duration: number = 3000): void {
|
|
||||||
this.snackBar.open(message, 'Close', {
|
|
||||||
duration,
|
|
||||||
panelClass: ['success-snackbar'],
|
|
||||||
horizontalPosition: 'center',
|
|
||||||
verticalPosition: 'bottom'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message: string, duration: number = 5000): void {
|
|
||||||
this.snackBar.open(message, 'Close', {
|
|
||||||
duration,
|
|
||||||
panelClass: ['error-snackbar'],
|
|
||||||
horizontalPosition: 'center',
|
|
||||||
verticalPosition: 'bottom'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showInfo(message: string, duration: number = 3000): void {
|
|
||||||
this.snackBar.open(message, 'Close', {
|
|
||||||
duration,
|
|
||||||
panelClass: ['info-snackbar'],
|
|
||||||
horizontalPosition: 'center',
|
|
||||||
verticalPosition: 'bottom'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showWarning(message: string, duration: number = 4000): void {
|
|
||||||
this.snackBar.open(message, 'Close', {
|
|
||||||
duration,
|
|
||||||
panelClass: ['warning-snackbar'],
|
|
||||||
horizontalPosition: 'center',
|
|
||||||
verticalPosition: 'bottom'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showConfirmation(message: string, action: string = 'Confirm'): Promise<boolean> {
|
|
||||||
const snackBarRef = this.snackBar.open(message, action, {
|
|
||||||
duration: 10000,
|
|
||||||
panelClass: ['confirmation-snackbar'],
|
|
||||||
horizontalPosition: 'center',
|
|
||||||
verticalPosition: 'bottom'
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
snackBarRef.onAction().subscribe(() => resolve(true));
|
|
||||||
snackBarRef.afterDismissed().subscribe((info) => {
|
|
||||||
if (!info.dismissedByAction) {
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { AppComponent } from './app/app.component';
|
import { AppComponent } from './app/app';
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, appConfig)
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
/* Global styles for the Test Artifact Data Lake Angular app with Material Design */
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -7,23 +5,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Roboto, "Helvetica Neue", sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
background: linear-gradient(135deg, #3f51b5 0%, #9c27b0 100%);
|
background: #0f172a;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: white;
|
background: #1e293b;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -34,6 +33,21 @@ header {
|
|||||||
header h1 {
|
header h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #60a5fa;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 2px solid #60a5fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-info {
|
.header-info {
|
||||||
@@ -53,8 +67,8 @@ header h1 {
|
|||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #f7f9fc;
|
background: #0f172a;
|
||||||
border-bottom: 2px solid #e2e8f0;
|
border-bottom: 2px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
@@ -66,17 +80,22 @@ header h1 {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button:hover {
|
.tab-button:hover {
|
||||||
background: #e2e8f0;
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button.active {
|
.tab-button.active {
|
||||||
background: white;
|
background: #1e293b;
|
||||||
color: #667eea;
|
color: #60a5fa;
|
||||||
border-bottom: 3px solid #667eea;
|
border-bottom: 3px solid #60a5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
@@ -95,6 +114,52 @@ header h1 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-inline input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-inline input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-inline input::placeholder {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear:hover {
|
||||||
|
background: #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -103,25 +168,28 @@ header h1 {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #667eea;
|
background: #3b82f6;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: #5568d3;
|
background: #2563eb;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #e2e8f0;
|
background: #334155;
|
||||||
color: #475569;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: #cbd5e1;
|
background: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@@ -143,14 +211,9 @@ header h1 {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-badge {
|
.count-badge {
|
||||||
background: #f0f9ff;
|
background: #1e3a8a;
|
||||||
color: #0369a1;
|
color: #93c5fd;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -160,8 +223,9 @@ header h1 {
|
|||||||
|
|
||||||
.table-container {
|
.table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #334155;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -171,30 +235,61 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
background: #f7f9fc;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #475569;
|
color: #94a3b8;
|
||||||
border-bottom: 2px solid #e2e8f0;
|
border-bottom: 2px solid #334155;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable:hover {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sort-asc .sort-indicator::after {
|
||||||
|
content: '▲';
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sort-desc .sort-indicator::after {
|
||||||
|
content: '▼';
|
||||||
|
color: #60a5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding: 12px;
|
padding: 16px 12px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
tbody tr:hover {
|
||||||
background: #f7f9fc;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
padding: 40px !important;
|
padding: 40px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,68 +302,38 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-pass {
|
.result-pass {
|
||||||
background: #d1fae5;
|
background: #064e3b;
|
||||||
color: #065f46;
|
color: #6ee7b7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-fail {
|
.result-fail {
|
||||||
background: #fee2e2;
|
background: #7f1d1d;
|
||||||
color: #991b1b;
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-skip {
|
.result-skip {
|
||||||
background: #fef3c7;
|
background: #78350f;
|
||||||
color: #92400e;
|
color: #fcd34d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-error {
|
.result-error {
|
||||||
background: #fecaca;
|
background: #7f1d1d;
|
||||||
color: #7f1d1d;
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #e0e7ff;
|
background: #1e3a8a;
|
||||||
color: #3730a3;
|
color: #93c5fd;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag:hover {
|
|
||||||
background: #c7d2fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.removable {
|
|
||||||
background: #f87171;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.removable:hover {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.available {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.available:hover:not(.attached) {
|
|
||||||
background: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.attached {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-type-badge {
|
.file-type-badge {
|
||||||
background: #dbeafe;
|
background: #1e3a8a;
|
||||||
color: #1e40af;
|
color: #93c5fd;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -287,7 +352,7 @@ tbody tr:hover {
|
|||||||
|
|
||||||
#page-info {
|
#page-info {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-section, .query-section {
|
.upload-section, .query-section {
|
||||||
@@ -305,16 +370,10 @@ tbody tr:hover {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #475569;
|
color: #cbd5e1;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -326,56 +385,49 @@ select,
|
|||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #334155;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: border-color 0.3s;
|
transition: border-color 0.3s;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #667eea;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-status {
|
#upload-status {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-status.success {
|
#upload-status.success {
|
||||||
background: #d1fae5;
|
background: #064e3b;
|
||||||
color: #065f46;
|
color: #6ee7b7;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-status.error {
|
#upload-status.error {
|
||||||
background: #fee2e2;
|
background: #7f1d1d;
|
||||||
color: #991b1b;
|
color: #fca5a5;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-file {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
background: #f0f9ff;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -384,7 +436,7 @@ small {
|
|||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,14 +447,15 @@ small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white;
|
background: #1e293b;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
@@ -411,19 +464,19 @@ small {
|
|||||||
top: 20px;
|
top: 20px;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
color: #475569;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row:last-child {
|
.detail-row:last-child {
|
||||||
@@ -432,20 +485,29 @@ small {
|
|||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #475569;
|
color: #94a3b8;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
color: #64748b;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
background: #f7f9fc;
|
background: #0f172a;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #0f172a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
@@ -457,203 +519,68 @@ pre {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
padding: 8px;
|
||||||
padding: 6px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background 0.3s;
|
transition: all 0.3s;
|
||||||
|
color: #94a3b8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn:hover {
|
.icon-btn:hover {
|
||||||
background: #e2e8f0;
|
background: #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filename-link {
|
/* Ensure SVG icons inherit color */
|
||||||
color: #667eea;
|
.icon-btn svg {
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artifact link styles - softer blue */
|
||||||
|
.artifact-link {
|
||||||
|
color: #93c5fd;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transition: color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filename-link:hover {
|
.artifact-link:hover {
|
||||||
|
color: #bfdbfe;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Binaries and Tags cells */
|
/* Clickable row cursor */
|
||||||
.binaries-cell, .tags-cell {
|
tr.clickable {
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binaries-list, .tags-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binary-item {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-btn {
|
|
||||||
background: #e5e7eb;
|
|
||||||
color: #374151;
|
|
||||||
border: none;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-btn:hover {
|
/* Search icon color */
|
||||||
background: #d1d5db;
|
.search-icon {
|
||||||
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expanded-binaries, .expanded-tags {
|
/* Material Icons */
|
||||||
margin-top: 8px;
|
.material-icons {
|
||||||
display: flex;
|
font-family: 'Material Icons';
|
||||||
flex-wrap: wrap;
|
font-weight: normal;
|
||||||
gap: 4px;
|
font-style: normal;
|
||||||
}
|
font-size: 20px;
|
||||||
|
|
||||||
/* Tag Manager */
|
|
||||||
.tag-manager {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
word-wrap: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
direction: ltr;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-tags {
|
.material-icons.md-16 { font-size: 16px; }
|
||||||
display: flex;
|
.material-icons.md-18 { font-size: 18px; }
|
||||||
flex-wrap: wrap;
|
.material-icons.md-20 { font-size: 20px; }
|
||||||
gap: 4px;
|
.material-icons.md-24 { font-size: 24px; }
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-btn {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-btn:hover {
|
|
||||||
background: #5568d3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-btn.active {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-tag-form {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: 100;
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input, .scope-select, .scope-custom-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-toggle-btn {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
border: none;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-input {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-tags {
|
|
||||||
margin-top: 16px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-tags h4 {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #475569;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-group {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-group h5 {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-indicator {
|
|
||||||
margin-left: 4px;
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter chips */
|
|
||||||
.filter-info {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
background: #f7f9fc;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-info h4 {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #475569;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.form-row {
|
.form-row {
|
||||||
@@ -674,36 +601,7 @@ pre {
|
|||||||
padding: 8px 6px;
|
padding: 8px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.binaries-cell, .tags-cell {
|
.toolbar {
|
||||||
max-width: 150px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
|
||||||
|
|
||||||
/* Material Snackbar Styles */
|
|
||||||
.success-snackbar {
|
|
||||||
background-color: #4caf50 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-snackbar {
|
|
||||||
background-color: #f44336 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-snackbar {
|
|
||||||
background-color: #2196f3 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-snackbar {
|
|
||||||
background-color: #ff9800 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmation-snackbar {
|
|
||||||
background-color: #673ab7 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"src/main.ts"
|
|
||||||
],
|
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.d.ts"
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/out-tsc",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
@@ -11,17 +10,25 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022"
|
"module": "preserve"
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
"strictInjectionParameters": true,
|
"strictInjectionParameters": true,
|
||||||
"strictInputAccessModifiers": true,
|
"strictInputAccessModifiers": true,
|
||||||
|
"typeCheckHostBindings": true,
|
||||||
"strictTemplates": true
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.ts"
|
||||||
"src/**/*.d.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v2
|
|
||||||
name: datalake
|
|
||||||
description: Test Artifact Data Lake - Store and query test artifacts
|
|
||||||
type: application
|
|
||||||
version: 1.0.0
|
|
||||||
appVersion: "1.0.0"
|
|
||||||
keywords:
|
|
||||||
- testing
|
|
||||||
- artifacts
|
|
||||||
- storage
|
|
||||||
- data-lake
|
|
||||||
maintainers:
|
|
||||||
- name: Your Team
|
|
||||||
42
helm/README.md
Normal file
42
helm/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Helm Charts
|
||||||
|
|
||||||
|
This directory contains Helm charts for deploying Warehouse13.
|
||||||
|
|
||||||
|
## Current Chart (Recommended)
|
||||||
|
|
||||||
|
**Location:** `./warehouse13/`
|
||||||
|
|
||||||
|
The latest, fully-featured Helm chart with:
|
||||||
|
- Warehouse13 branding
|
||||||
|
- Configurable images for all components
|
||||||
|
- Multiple deployment scenarios (dev, production, air-gapped)
|
||||||
|
- Comprehensive documentation
|
||||||
|
- Example values files
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
helm install warehouse13 ./warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentation:** See [warehouse13/README.md](./warehouse13/README.md)
|
||||||
|
|
||||||
|
## Migration from Legacy Chart
|
||||||
|
|
||||||
|
If you were using an older version of the chart, migration is straightforward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Uninstall old chart (if named "datalake" or other name)
|
||||||
|
helm uninstall <old-release-name>
|
||||||
|
|
||||||
|
# Install new chart
|
||||||
|
helm install warehouse13 ./warehouse13 --namespace warehouse13 --create-namespace
|
||||||
|
|
||||||
|
# Or upgrade in place (if compatible)
|
||||||
|
helm upgrade <old-release-name> ./warehouse13
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Check your values.yaml configuration and update image repositories, resource limits, and other settings as needed.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
See [../docs/HELM-DEPLOYMENT.md](../docs/HELM-DEPLOYMENT.md) for comprehensive deployment guide.
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: {{ include "datalake.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "datalake.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
{{- if not .Values.autoscaling.enabled }}
|
|
||||||
replicas: {{ .Values.replicaCount }}
|
|
||||||
{{- end }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "datalake.selectorLabels" . | nindent 6 }}
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
{{- with .Values.podAnnotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
{{- include "datalake.selectorLabels" . | nindent 8 }}
|
|
||||||
spec:
|
|
||||||
{{- with .Values.imagePullSecrets }}
|
|
||||||
imagePullSecrets:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
serviceAccountName: {{ include "datalake.serviceAccountName" . }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
|
||||||
containers:
|
|
||||||
- name: {{ .Chart.Name }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: {{ .Values.service.targetPort }}
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
env:
|
|
||||||
- name: DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "datalake.fullname" . }}-secrets
|
|
||||||
key: database-url
|
|
||||||
- name: STORAGE_BACKEND
|
|
||||||
value: {{ .Values.config.storageBackend | quote }}
|
|
||||||
- name: MAX_UPLOAD_SIZE
|
|
||||||
value: {{ .Values.config.maxUploadSize | quote }}
|
|
||||||
{{- if eq .Values.config.storageBackend "s3" }}
|
|
||||||
- name: AWS_ACCESS_KEY_ID
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "datalake.fullname" . }}-secrets
|
|
||||||
key: aws-access-key-id
|
|
||||||
- name: AWS_SECRET_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "datalake.fullname" . }}-secrets
|
|
||||||
key: aws-secret-access-key
|
|
||||||
- name: AWS_REGION
|
|
||||||
value: {{ .Values.aws.region | quote }}
|
|
||||||
- name: S3_BUCKET_NAME
|
|
||||||
value: {{ .Values.aws.bucketName | quote }}
|
|
||||||
{{- else }}
|
|
||||||
- name: MINIO_ENDPOINT
|
|
||||||
value: "{{ include "datalake.fullname" . }}-minio:9000"
|
|
||||||
- name: MINIO_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "datalake.fullname" . }}-secrets
|
|
||||||
key: minio-access-key
|
|
||||||
- name: MINIO_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "datalake.fullname" . }}-secrets
|
|
||||||
key: minio-secret-key
|
|
||||||
- name: MINIO_BUCKET_NAME
|
|
||||||
value: "test-artifacts"
|
|
||||||
- name: MINIO_SECURE
|
|
||||||
value: "false"
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.env }}
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
|
||||||
{{- with .Values.nodeSelector }}
|
|
||||||
nodeSelector:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: {{ include "datalake.fullname" . }}-secrets
|
|
||||||
labels:
|
|
||||||
{{- include "datalake.labels" . | nindent 4 }}
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
database-url: "postgresql://{{ .Values.postgresql.auth.username }}:{{ .Values.postgresql.auth.password }}@{{ include "datalake.fullname" . }}-postgresql:5432/{{ .Values.postgresql.auth.database }}"
|
|
||||||
{{- if .Values.aws.enabled }}
|
|
||||||
aws-access-key-id: {{ .Values.aws.accessKeyId | quote }}
|
|
||||||
aws-secret-access-key: {{ .Values.aws.secretAccessKey | quote }}
|
|
||||||
{{- else }}
|
|
||||||
minio-access-key: {{ .Values.minio.rootUser | quote }}
|
|
||||||
minio-secret-key: {{ .Values.minio.rootPassword | quote }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: {{ include "datalake.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "datalake.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
type: {{ .Values.service.type }}
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.service.port }}
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
name: http
|
|
||||||
selector:
|
|
||||||
{{- include "datalake.selectorLabels" . | nindent 4 }}
|
|
||||||
111
helm/values.yaml
111
helm/values.yaml
@@ -1,111 +0,0 @@
|
|||||||
replicaCount: 1
|
|
||||||
|
|
||||||
image:
|
|
||||||
repository: datalake
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
tag: "latest"
|
|
||||||
|
|
||||||
imagePullSecrets: []
|
|
||||||
nameOverride: ""
|
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
serviceAccount:
|
|
||||||
create: true
|
|
||||||
annotations: {}
|
|
||||||
name: ""
|
|
||||||
|
|
||||||
podAnnotations: {}
|
|
||||||
|
|
||||||
podSecurityContext:
|
|
||||||
fsGroup: 1000
|
|
||||||
|
|
||||||
securityContext:
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 1000
|
|
||||||
|
|
||||||
service:
|
|
||||||
type: ClusterIP
|
|
||||||
port: 8000
|
|
||||||
targetPort: 8000
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
enabled: false
|
|
||||||
className: ""
|
|
||||||
annotations: {}
|
|
||||||
hosts:
|
|
||||||
- host: datalake.local
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
tls: []
|
|
||||||
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: 1000m
|
|
||||||
memory: 1Gi
|
|
||||||
requests:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 512Mi
|
|
||||||
|
|
||||||
autoscaling:
|
|
||||||
enabled: false
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 10
|
|
||||||
targetCPUUtilizationPercentage: 80
|
|
||||||
|
|
||||||
nodeSelector: {}
|
|
||||||
|
|
||||||
tolerations: []
|
|
||||||
|
|
||||||
affinity: {}
|
|
||||||
|
|
||||||
# Application configuration
|
|
||||||
config:
|
|
||||||
storageBackend: minio # or "s3"
|
|
||||||
maxUploadSize: 524288000 # 500MB
|
|
||||||
|
|
||||||
# PostgreSQL configuration
|
|
||||||
postgresql:
|
|
||||||
enabled: true
|
|
||||||
auth:
|
|
||||||
username: user
|
|
||||||
password: password
|
|
||||||
database: datalake
|
|
||||||
primary:
|
|
||||||
persistence:
|
|
||||||
enabled: true
|
|
||||||
size: 10Gi
|
|
||||||
|
|
||||||
# MinIO configuration (for self-hosted storage)
|
|
||||||
minio:
|
|
||||||
enabled: true
|
|
||||||
mode: standalone
|
|
||||||
rootUser: minioadmin
|
|
||||||
rootPassword: minioadmin
|
|
||||||
persistence:
|
|
||||||
enabled: true
|
|
||||||
size: 50Gi
|
|
||||||
service:
|
|
||||||
type: ClusterIP
|
|
||||||
port: 9000
|
|
||||||
consoleService:
|
|
||||||
port: 9001
|
|
||||||
|
|
||||||
# AWS S3 configuration (when using AWS)
|
|
||||||
aws:
|
|
||||||
enabled: false
|
|
||||||
accessKeyId: ""
|
|
||||||
secretAccessKey: ""
|
|
||||||
region: us-east-1
|
|
||||||
bucketName: test-artifacts
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
env:
|
|
||||||
- name: API_HOST
|
|
||||||
value: "0.0.0.0"
|
|
||||||
- name: API_PORT
|
|
||||||
value: "8000"
|
|
||||||
23
helm/warehouse13/.helmignore
Normal file
23
helm/warehouse13/.helmignore
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Patterns to ignore when building packages.
|
||||||
|
# This supports shell glob matching, relative path matching, and
|
||||||
|
# negation (prefixed with !). Only one pattern per line.
|
||||||
|
.DS_Store
|
||||||
|
# Common VCS dirs
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
# Common backup files
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
# Various IDEs
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
281
helm/warehouse13/ARCHITECTURE.md
Normal file
281
helm/warehouse13/ARCHITECTURE.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Warehouse13 Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Warehouse13 uses a **unified application container** that includes both the frontend and backend in a single Docker image using a multi-stage build.
|
||||||
|
|
||||||
|
## Docker Build Strategy
|
||||||
|
|
||||||
|
### Multi-Stage Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Stage 1: Build Angular Frontend
|
||||||
|
FROM node:24-alpine AS frontend-build
|
||||||
|
WORKDIR /frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build:prod
|
||||||
|
|
||||||
|
# Stage 2: Python Backend with Static Frontend
|
||||||
|
FROM python:3.11-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
# Copy backend code
|
||||||
|
COPY app/ ./app/
|
||||||
|
# Copy built frontend from stage 1
|
||||||
|
COPY --from=frontend-build /frontend/dist/frontend/browser ./static/
|
||||||
|
# Run FastAPI server
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **Simplified Deployment** - Single container to manage
|
||||||
|
2. **Reduced Resource Usage** - No separate nginx container needed
|
||||||
|
3. **Easier Scaling** - Scale one deployment instead of two
|
||||||
|
4. **Consistent Versioning** - Frontend and backend versions always match
|
||||||
|
5. **Faster Deployments** - Fewer containers to orchestrate
|
||||||
|
|
||||||
|
## Service Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ warehouse13-app │
|
||||||
|
│ ┌────────────────────────────────────┐ │
|
||||||
|
│ │ FastAPI Backend (Port 8000) │ │
|
||||||
|
│ │ ├── /api/* → REST API │ │
|
||||||
|
│ │ ├── /health → Health check │ │
|
||||||
|
│ │ ├── /docs → API documentation │ │
|
||||||
|
│ │ └── /* → Angular SPA │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Static Files: /static/ │ │
|
||||||
|
│ │ └── Angular build output │ │
|
||||||
|
│ └────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├────────────────┐
|
||||||
|
↓ ↓
|
||||||
|
┌──────────┐ ┌────────────┐
|
||||||
|
│PostgreSQL│ │ MinIO │
|
||||||
|
│(Metadata)│ │ (Blobs) │
|
||||||
|
└──────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helm Chart Structure
|
||||||
|
|
||||||
|
### Single Application Deployment
|
||||||
|
|
||||||
|
The Helm chart creates:
|
||||||
|
|
||||||
|
1. **1 Deployment**: `warehouse13-app`
|
||||||
|
- Runs the unified container
|
||||||
|
- Configurable replicas (default: 2)
|
||||||
|
- Health checks on `/health` endpoint
|
||||||
|
|
||||||
|
2. **1 Service**: `warehouse13-app`
|
||||||
|
- Exposes port 8000
|
||||||
|
- Routes all traffic to the application
|
||||||
|
|
||||||
|
3. **Optional Ingress**
|
||||||
|
- All paths route to `warehouse13-app:8000`
|
||||||
|
- FastAPI handles routing internally
|
||||||
|
|
||||||
|
### Kubernetes Resources
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# warehouse13-app Deployment
|
||||||
|
- Replicas: 2 (configurable)
|
||||||
|
- Port: 8000
|
||||||
|
- Health checks: /health
|
||||||
|
- Environment: DATABASE_URL, MINIO_* vars
|
||||||
|
|
||||||
|
# warehouse13-app Service
|
||||||
|
- Type: ClusterIP
|
||||||
|
- Port: 8000 → 8000
|
||||||
|
|
||||||
|
# Ingress (optional)
|
||||||
|
- Path: / → warehouse13-app:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Image Configuration
|
||||||
|
|
||||||
|
In `values.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
enabled: true
|
||||||
|
image:
|
||||||
|
repository: warehouse13/app # Single unified image
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
replicas: 2
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "384Mi" # Combined frontend + backend
|
||||||
|
cpu: "350m"
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
cpu: "750m"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing the Application
|
||||||
|
|
||||||
|
**Via Port Forward:**
|
||||||
|
```bash
|
||||||
|
kubectl port-forward svc/warehouse13-app 8000:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Then access:
|
||||||
|
- Frontend: http://localhost:8000
|
||||||
|
- API: http://localhost:8000/api
|
||||||
|
- API Docs: http://localhost:8000/docs
|
||||||
|
- Health: http://localhost:8000/health
|
||||||
|
|
||||||
|
**Via Ingress:**
|
||||||
|
```yaml
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
hosts:
|
||||||
|
- host: warehouse13.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend: app # All traffic to one service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from Separate Services
|
||||||
|
|
||||||
|
If you previously had separate `api` and `frontend` deployments:
|
||||||
|
|
||||||
|
### Before (Old Architecture)
|
||||||
|
```yaml
|
||||||
|
# values.yaml (old)
|
||||||
|
api:
|
||||||
|
image: warehouse13/api
|
||||||
|
replicas: 2
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: warehouse13/frontend
|
||||||
|
replicas: 2
|
||||||
|
|
||||||
|
# Two deployments, two services
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Current Architecture)
|
||||||
|
```yaml
|
||||||
|
# values.yaml (current)
|
||||||
|
app:
|
||||||
|
image: warehouse13/app # Unified image
|
||||||
|
replicas: 2
|
||||||
|
|
||||||
|
# One deployment, one service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Steps
|
||||||
|
|
||||||
|
1. **Update values.yaml** - Change from `api`/`frontend` to `app`
|
||||||
|
2. **Update image references** - Use `warehouse13/app` instead of separate images
|
||||||
|
3. **Update ingress** - Point all paths to `app` backend
|
||||||
|
4. **Deploy** - Helm will handle the transition
|
||||||
|
5. **Verify** - Check that both frontend and API work through single service
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Building the Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build unified image
|
||||||
|
docker build -t warehouse13/app:dev .
|
||||||
|
|
||||||
|
# Or for air-gapped environments with custom registry
|
||||||
|
docker build \
|
||||||
|
--build-arg NPM_REGISTRY=https://registry.npmjs.org/ \
|
||||||
|
-t warehouse13/app:v1.0.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8000:8000 \
|
||||||
|
-e DATABASE_URL=postgresql://user:pass@host/db \
|
||||||
|
-e MINIO_ENDPOINT=minio:9000 \
|
||||||
|
warehouse13/app:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Access:
|
||||||
|
- Frontend: http://localhost:8000
|
||||||
|
- API: http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Resource Allocation
|
||||||
|
|
||||||
|
The unified container combines both frontend serving and API processing:
|
||||||
|
|
||||||
|
- **Memory**: Angular assets (~50MB) + Python runtime (~100MB) + working memory
|
||||||
|
- **CPU**: Primarily used for API requests; static file serving is lightweight
|
||||||
|
- **Recommended Minimum**: 384Mi memory, 350m CPU
|
||||||
|
- **Production**: 768Mi memory, 750m CPU per replica
|
||||||
|
|
||||||
|
### Scaling Strategy
|
||||||
|
|
||||||
|
Scale horizontally by increasing replicas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scale to 5 replicas
|
||||||
|
kubectl scale deployment warehouse13-app --replicas=5
|
||||||
|
|
||||||
|
# Or via Helm
|
||||||
|
helm upgrade warehouse13 ./helm/warehouse13 --set app.replicas=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
FastAPI serves static files efficiently with:
|
||||||
|
- ETag support
|
||||||
|
- Browser caching headers
|
||||||
|
- Gzip compression (if enabled in FastAPI config)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Frontend Not Loading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if static files exist in container
|
||||||
|
kubectl exec -it warehouse13-app-xxx -- ls -la /app/static/
|
||||||
|
|
||||||
|
# Should see: index.html, *.js, *.css files
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Not Working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check API health
|
||||||
|
kubectl exec -it warehouse13-app-xxx -- curl http://localhost:8000/health
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
kubectl logs warehouse13-app-xxx -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Both Frontend and API Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if app is running
|
||||||
|
kubectl get pods -l app.kubernetes.io/component=app
|
||||||
|
|
||||||
|
# Check service
|
||||||
|
kubectl get svc warehouse13-app
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
kubectl port-forward svc/warehouse13-app 8000:8000
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The unified architecture simplifies deployment and operations while maintaining the same functionality. All routing, caching, and API requests are handled by a single FastAPI application that serves both the Angular SPA and the REST API endpoints.
|
||||||
16
helm/warehouse13/Chart.yaml
Normal file
16
helm/warehouse13/Chart.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: warehouse13
|
||||||
|
description: Warehouse13 - Enterprise Test Artifact Storage
|
||||||
|
type: application
|
||||||
|
version: 1.0.0
|
||||||
|
appVersion: "1.0.0"
|
||||||
|
keywords:
|
||||||
|
- testing
|
||||||
|
- artifacts
|
||||||
|
- storage
|
||||||
|
- datalake
|
||||||
|
maintainers:
|
||||||
|
- name: Warehouse13 Team
|
||||||
|
home: https://github.com/yourusername/warehouse13
|
||||||
|
sources:
|
||||||
|
- https://github.com/yourusername/warehouse13
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user