Compare commits
2 Commits
dcd782ad1a
...
14d85614ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d85614ed | ||
|
|
7c1346caac |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -86,3 +86,10 @@ helm/charts/
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Node.js
|
||||
package-lock.json
|
||||
**/package-lock.json
|
||||
|
||||
# Built static files (generated during Docker build from Angular)
|
||||
static/
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,3 +1,30 @@
|
||||
# Multi-stage build: First stage builds Angular frontend
|
||||
FROM node:24-alpine AS frontend-build
|
||||
|
||||
# Accept npm registry as build argument
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Configure npm registry if custom registry is provided
|
||||
RUN if [ "$NPM_REGISTRY" != "https://registry.npmjs.org/" ]; then \
|
||||
echo "Using custom npm registry: $NPM_REGISTRY"; \
|
||||
npm config set registry "$NPM_REGISTRY"; \
|
||||
fi
|
||||
|
||||
# Install dependencies (ignore package-lock.json if using custom registry)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build for production
|
||||
RUN npm run build:prod
|
||||
|
||||
# Second stage: Python backend with Angular frontend
|
||||
FROM python:3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
@@ -20,7 +47,9 @@ COPY app/ ./app/
|
||||
COPY utils/ ./utils/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
COPY static/ ./static/
|
||||
|
||||
# Copy built Angular frontend from first stage to static directory
|
||||
COPY --from=frontend-build /frontend/dist/frontend/browser ./static/
|
||||
|
||||
# Create non-root user (Alpine uses adduser instead of useradd)
|
||||
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Multi-stage build for Angular frontend
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
# Accept npm registry as build argument
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Configure npm registry and regenerate package-lock.json 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"; \
|
||||
rm -f package-lock.json; \
|
||||
npm install --package-lock-only; \
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build for production
|
||||
RUN npm run build:prod
|
||||
|
||||
# Final stage - nginx to serve static files
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built Angular app to nginx
|
||||
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Dockerfile for pre-built Angular frontend (air-gapped/restricted environments)
|
||||
#
|
||||
# IMPORTANT: You must build the Angular app BEFORE running docker-compose!
|
||||
# Run this command first: ./build-for-airgap.sh
|
||||
# OR manually: cd frontend && npm install && npm run build:prod
|
||||
#
|
||||
# This Dockerfile expects frontend/dist/frontend/browser to exist
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy pre-built Angular app to nginx
|
||||
# If this step fails, you need to run: ./build-for-airgap.sh
|
||||
COPY frontend/dist/frontend/browser /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -48,11 +48,6 @@ A lightweight, cloud-native API for storing and querying test artifacts includin
|
||||
.\quickstart.ps1
|
||||
```
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```batch
|
||||
quickstart.bat
|
||||
```
|
||||
|
||||
### Air-Gapped/Restricted Environment Deployment
|
||||
|
||||
**For environments with restricted npm access:**
|
||||
@@ -65,7 +60,7 @@ This script:
|
||||
2. Packages pre-built files into Docker
|
||||
3. Starts all services
|
||||
|
||||
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
|
||||
See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for detailed instructions.
|
||||
|
||||
### Manual Setup with Docker Compose
|
||||
|
||||
|
||||
30
app/main.py
30
app/main.py
@@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -39,10 +39,8 @@ app.add_middleware(
|
||||
app.include_router(artifacts_router)
|
||||
app.include_router(seed_router)
|
||||
|
||||
# Mount static files
|
||||
# Static directory setup
|
||||
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -84,6 +82,30 @@ async def ui_root():
|
||||
}
|
||||
|
||||
|
||||
# Catch-all route for Angular SPA routing - must be last
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
"""Serve Angular SPA static files and handle client-side routing"""
|
||||
# Try to serve static file first (JS, CSS, images, etc.)
|
||||
file_path = os.path.join(static_dir, full_path)
|
||||
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
|
||||
# For all other routes (Angular client-side routes), serve index.html
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
else:
|
||||
return {
|
||||
"message": "Warehouse13 - Enterprise Test Artifact Storage",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"ui": "UI not found. Serving API only.",
|
||||
"deployment_mode": settings.deployment_mode,
|
||||
"storage_backend": settings.storage_backend
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo "Building Angular for Air-Gapped Deployment"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
echo "Error: Must run from project root directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if node is installed
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "Error: Node.js is not installed. Please install Node.js 18+ first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if npm is installed
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo "Error: npm is not installed. Please install npm first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Step 1/3: Installing dependencies..."
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
echo ""
|
||||
echo "Step 2/3: Building Angular production bundle..."
|
||||
npm run build:prod
|
||||
|
||||
echo ""
|
||||
echo "Step 3/3: Verifying build output..."
|
||||
if [ -d "dist/frontend/browser" ]; then
|
||||
echo "✓ Build successful!"
|
||||
echo "✓ Output: frontend/dist/frontend/browser"
|
||||
ls -lh dist/frontend/browser | head -5
|
||||
else
|
||||
echo "✗ Build failed - output directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Build Complete!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Update docker-compose.yml:"
|
||||
echo " Change: dockerfile: Dockerfile.frontend"
|
||||
echo " To: dockerfile: Dockerfile.frontend.prebuilt"
|
||||
echo ""
|
||||
echo "2. Deploy:"
|
||||
echo " docker-compose up -d --build"
|
||||
echo ""
|
||||
echo "See DEPLOYMENT.md for more details."
|
||||
echo "========================================="
|
||||
@@ -36,7 +36,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
app:
|
||||
container_name: warehouse13-app
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -59,20 +60,6 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend.prebuilt
|
||||
ports:
|
||||
- "4200:80"
|
||||
depends_on:
|
||||
- api
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
||||
@@ -93,5 +93,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
|
||||
5036
frontend/package-lock.json
generated
5036
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
nginx.conf
36
nginx.conf
@@ -1,36 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# Angular routes - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -28,17 +28,12 @@ if ! command -v docker-compose &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Step 1: Building Angular frontend locally..."
|
||||
echo "==========================================="
|
||||
./build-for-airgap.sh
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Starting Docker containers..."
|
||||
echo "Step 1: Starting Docker containers..."
|
||||
echo "==========================================="
|
||||
docker-compose up -d --build
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Waiting for services to be ready..."
|
||||
echo "Step 2: Waiting for services to be ready..."
|
||||
sleep 15
|
||||
|
||||
echo ""
|
||||
@@ -46,8 +41,7 @@ echo "========================================="
|
||||
echo "Services are running!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Frontend: http://localhost:4200"
|
||||
echo "API: http://localhost:8000"
|
||||
echo "Web UI: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo "MinIO Console: http://localhost:9001"
|
||||
echo " Username: minioadmin"
|
||||
|
||||
106
quickstart.bat
106
quickstart.bat
@@ -1,106 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo =========================================
|
||||
echo Warehouse13 - Quick Start
|
||||
echo =========================================
|
||||
echo.
|
||||
|
||||
REM Check if Docker is installed
|
||||
where docker >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Docker is not installed. Please install Docker Desktop first.
|
||||
echo Visit: https://www.docker.com/products/docker-desktop
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Check if Docker Compose is available
|
||||
where docker-compose >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
REM Try docker compose (new version)
|
||||
docker compose version >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Docker Compose is not available.
|
||||
echo Please ensure Docker Desktop is running.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set COMPOSE_CMD=docker compose
|
||||
) else (
|
||||
set COMPOSE_CMD=docker-compose
|
||||
)
|
||||
|
||||
REM Create .env file if it doesn't exist
|
||||
if not exist .env (
|
||||
echo Creating .env file from .env.example...
|
||||
copy .env.example .env >nul
|
||||
echo [OK] .env file created
|
||||
) else (
|
||||
echo [OK] .env file already exists
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Building and starting services with Docker Compose...
|
||||
%COMPOSE_CMD% up -d --build
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo Error: Failed to start services.
|
||||
echo Make sure Docker Desktop is running.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Waiting for services to be ready...
|
||||
timeout /t 15 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Services are running!
|
||||
echo =========================================
|
||||
echo.
|
||||
echo Web UI: http://localhost:8000
|
||||
echo API Docs: http://localhost:8000/docs
|
||||
echo MinIO Console: http://localhost:9001
|
||||
echo Username: minioadmin
|
||||
echo Password: minioadmin
|
||||
echo.
|
||||
echo To view logs: %COMPOSE_CMD% logs -f
|
||||
echo To stop: %COMPOSE_CMD% down
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Testing the API...
|
||||
echo =========================================
|
||||
echo.
|
||||
|
||||
REM Wait a bit more for API to be fully ready
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
REM Test health endpoint
|
||||
curl -s http://localhost:8000/health | findstr "healthy" >nul 2>nul
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] API is healthy!
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Open your browser to get started:
|
||||
echo http://localhost:8000
|
||||
echo =========================================
|
||||
) else (
|
||||
echo [WARNING] API is not responding yet.
|
||||
echo Please wait a moment and check http://localhost:8000
|
||||
)
|
||||
|
||||
echo.
|
||||
echo =========================================
|
||||
echo Setup complete!
|
||||
echo =========================================
|
||||
echo.
|
||||
echo Press any key to open the UI in your browser...
|
||||
pause >nul
|
||||
|
||||
REM Open browser
|
||||
start http://localhost:8000
|
||||
|
||||
exit /b 0
|
||||
@@ -121,7 +121,7 @@ Write-Host "Useful Commands:" -ForegroundColor Cyan
|
||||
Write-Host " Generate seed data: " -NoNewline
|
||||
Write-Host "Use the 'Generate Seed Data' button in the UI" -ForegroundColor Yellow
|
||||
Write-Host " View logs: " -NoNewline
|
||||
Write-Host "$composeCmd logs -f api" -ForegroundColor Yellow
|
||||
Write-Host "$composeCmd logs -f app" -ForegroundColor Yellow
|
||||
Write-Host " Restart services: " -NoNewline
|
||||
Write-Host "$composeCmd restart" -ForegroundColor Yellow
|
||||
Write-Host " Stop all: " -NoNewline
|
||||
|
||||
@@ -41,7 +41,7 @@ echo "========================================="
|
||||
echo "Services are running!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "API: http://localhost:8000"
|
||||
echo "Web UI: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo "MinIO Console: http://localhost:9001"
|
||||
echo " Username: minioadmin"
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
color: #60a5fa;
|
||||
letter-spacing: -1px;
|
||||
padding: 2px 4px;
|
||||
border: 2px solid #60a5fa;
|
||||
border-radius: 4px;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #0f172a;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 16px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #1e293b;
|
||||
color: #60a5fa;
|
||||
border-bottom: 3px solid #60a5fa;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #0f172a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #334155;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filter-inline input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-inline input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filter-inline input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 14px 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
background: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 14px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
border-bottom: 2px solid #334155;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
th.sortable:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
th.sort-asc .sort-indicator::after {
|
||||
content: '▲';
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
th.sort-desc .sort-indicator::after {
|
||||
content: '▼';
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 40px !important;
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-pass {
|
||||
background: #064e3b;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.result-fail {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.result-skip {
|
||||
background: #78350f;
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.file-type-badge {
|
||||
background: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#page-info {
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.upload-section, .query-section {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
input[type="datetime-local"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.3s;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#upload-status {
|
||||
margin-top: 20px;
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#upload-status.success {
|
||||
background: #064e3b;
|
||||
color: #6ee7b7;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#upload-status.error {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
max-width: 700px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0f172a;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #0f172a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Ensure SVG icons inherit color */
|
||||
.icon-btn svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warehouse13 - Test Artifact Data Lake</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1><span class="logo">[W13]</span></h1>
|
||||
<div class="header-info">
|
||||
<span id="deployment-mode" class="badge"></span>
|
||||
<span id="storage-backend" class="badge"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab-button active" onclick="showTab('artifacts')">
|
||||
<i data-lucide="database" style="width: 16px; height: 16px;"></i> Artifacts
|
||||
</button>
|
||||
<button class="tab-button" onclick="showTab('upload')">
|
||||
<i data-lucide="upload" style="width: 16px; height: 16px;"></i> Upload
|
||||
</button>
|
||||
<button class="tab-button" onclick="showTab('query')">
|
||||
<i data-lucide="search" style="width: 16px; height: 16px;"></i> Query
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Artifacts Tab -->
|
||||
<div id="artifacts-tab" class="tab-content active">
|
||||
<div class="toolbar">
|
||||
<button onclick="loadArtifacts()" class="btn btn-primary">
|
||||
<i data-lucide="refresh-cw" style="width: 16px; height: 16px;"></i> Refresh
|
||||
</button>
|
||||
<button id="auto-refresh-toggle" onclick="toggleAutoRefresh()" class="btn btn-success">
|
||||
Auto-refresh: ON
|
||||
</button>
|
||||
<button onclick="generateSeedData()" class="btn btn-secondary">
|
||||
<i data-lucide="sparkles" style="width: 16px; height: 16px;"></i> Generate Seed Data
|
||||
</button>
|
||||
|
||||
<span id="artifact-count" class="count-badge"></span>
|
||||
|
||||
<div class="filter-inline">
|
||||
<i data-lucide="search" style="width: 16px; height: 16px; color: #64748b;"></i>
|
||||
<input type="text" id="filter-search" placeholder="Search..." oninput="filterTable()">
|
||||
<button onclick="clearFilters()" class="btn-clear" title="Clear search">
|
||||
<i data-lucide="x" style="width: 14px; height: 14px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="artifacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortTable('test_suite')">
|
||||
Sim Source <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('filename')">
|
||||
Artifacts <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('created_at')">
|
||||
Date <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('test_name')">
|
||||
Uploaded By <span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="artifacts-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">Loading artifacts...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button onclick="previousPage()" id="prev-btn" class="btn">← Previous</button>
|
||||
<span id="page-info">Page 1</span>
|
||||
<button onclick="nextPage()" id="next-btn" class="btn">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Tab -->
|
||||
<div id="upload-tab" class="tab-content">
|
||||
<div class="upload-section">
|
||||
<h2>Upload Artifact</h2>
|
||||
<form id="upload-form" onsubmit="uploadArtifact(event)">
|
||||
<div class="form-group">
|
||||
<label for="file">File *</label>
|
||||
<input type="file" id="file" name="file" required>
|
||||
<small>Supported: CSV, JSON, binary files, PCAP</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sim-source">Sim Source *</label>
|
||||
<input type="text" id="sim-source" name="test_suite" placeholder="e.g., Jenkins, GitLab CI" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uploaded-by">Uploaded By *</label>
|
||||
<input type="text" id="uploaded-by" name="test_name" placeholder="e.g., john.doe" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sim-source-id">SIM Source ID (for grouping)</label>
|
||||
<input type="text" id="sim-source-id" name="sim_source_id" placeholder="e.g., sim_run_20251015_001">
|
||||
<small>Use same ID for multiple artifacts from same source</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (comma-separated) *</label>
|
||||
<input type="text" id="tags" name="tags" placeholder="e.g., regression, smoke, critical" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="test-result">Test Result</label>
|
||||
<select id="test-result" name="test_result">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="pass">Pass</option>
|
||||
<option value="fail">Fail</option>
|
||||
<option value="skip">Skip</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="version">Version</label>
|
||||
<input type="text" id="version" name="version" placeholder="e.g., v1.0.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="Describe this artifact..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="test-config">Test Config (JSON)</label>
|
||||
<textarea id="test-config" name="test_config" rows="4" placeholder='{"browser": "chrome", "timeout": 30}'></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="custom-metadata">Custom Metadata (JSON)</label>
|
||||
<textarea id="custom-metadata" name="custom_metadata" rows="4" placeholder='{"build": "1234", "commit": "abc123"}'></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large">
|
||||
<i data-lucide="upload" style="width: 18px; height: 18px;"></i> Upload Artifact
|
||||
</button>
|
||||
</form>
|
||||
<div id="upload-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Query Tab -->
|
||||
<div id="query-tab" class="tab-content">
|
||||
<div class="query-section">
|
||||
<h2>Query Artifacts</h2>
|
||||
<form id="query-form" onsubmit="queryArtifacts(event)">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-filename">Filename</label>
|
||||
<input type="text" id="q-filename" placeholder="Search filename...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-type">File Type</label>
|
||||
<select id="q-type">
|
||||
<option value="">All</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="binary">Binary</option>
|
||||
<option value="pcap">PCAP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-test-name">Test Name</label>
|
||||
<input type="text" id="q-test-name" placeholder="Search test name...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-suite">Test Suite</label>
|
||||
<input type="text" id="q-suite" placeholder="e.g., integration">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-result">Test Result</label>
|
||||
<select id="q-result">
|
||||
<option value="">All</option>
|
||||
<option value="pass">Pass</option>
|
||||
<option value="fail">Fail</option>
|
||||
<option value="skip">Skip</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-tags">Tags (comma-separated)</label>
|
||||
<input type="text" id="q-tags" placeholder="e.g., regression, smoke">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="q-start-date">Start Date</label>
|
||||
<input type="datetime-local" id="q-start-date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="q-end-date">End Date</label>
|
||||
<input type="datetime-local" id="q-end-date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large">
|
||||
<i data-lucide="search" style="width: 18px; height: 18px;"></i> Search
|
||||
</button>
|
||||
<button type="button" onclick="clearQuery()" class="btn btn-secondary">
|
||||
<i data-lucide="x" style="width: 18px; height: 18px;"></i> Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artifact Detail Modal -->
|
||||
<div id="detail-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeDetailModal()">×</span>
|
||||
<h2>Artifact Details</h2>
|
||||
<div id="detail-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
// Initialize Lucide icons
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
592
static/js/app.js
592
static/js/app.js
@@ -1,592 +0,0 @@
|
||||
// API Base URL
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Pagination
|
||||
let currentPage = 1;
|
||||
let pageSize = 25;
|
||||
let totalArtifacts = 0;
|
||||
|
||||
// Auto-refresh
|
||||
let autoRefreshEnabled = true;
|
||||
let autoRefreshInterval = null;
|
||||
const REFRESH_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
// Sorting and filtering
|
||||
let allArtifacts = []; // Store all artifacts for client-side sorting/filtering
|
||||
let currentSortColumn = null;
|
||||
let currentSortDirection = 'asc';
|
||||
|
||||
// Load API info on page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
loadApiInfo();
|
||||
loadArtifacts();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
// Load API information
|
||||
async function loadApiInfo() {
|
||||
try {
|
||||
const response = await fetch('/api');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('deployment-mode').textContent = `Mode: ${data.deployment_mode}`;
|
||||
document.getElementById('storage-backend').textContent = `Storage: ${data.storage_backend}`;
|
||||
} catch (error) {
|
||||
console.error('Error loading API info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load artifacts
|
||||
async function loadArtifacts(limit = pageSize, offset = 0) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/?limit=${limit}&offset=${offset}`);
|
||||
const artifacts = await response.json();
|
||||
|
||||
allArtifacts = artifacts; // Store for sorting/filtering
|
||||
displayArtifacts(artifacts);
|
||||
updatePagination(artifacts.length);
|
||||
} catch (error) {
|
||||
console.error('Error loading artifacts:', error);
|
||||
document.getElementById('artifacts-tbody').innerHTML = `
|
||||
<tr><td colspan="5" class="loading" style="color: #ef4444;">
|
||||
Error loading artifacts: ${error.message}
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display artifacts in table
|
||||
function displayArtifacts(artifacts) {
|
||||
const tbody = document.getElementById('artifacts-tbody');
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading">No artifacts found. Upload some files to get started!</td></tr>';
|
||||
document.getElementById('artifact-count').textContent = '0 artifacts';
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply current sort if active
|
||||
let displayedArtifacts = artifacts;
|
||||
if (currentSortColumn) {
|
||||
displayedArtifacts = applySorting([...artifacts]);
|
||||
}
|
||||
|
||||
tbody.innerHTML = displayedArtifacts.map(artifact => `
|
||||
<tr>
|
||||
<td>${artifact.sim_source_id || artifact.test_suite || '-'}</td>
|
||||
<td>
|
||||
<a href="#" onclick="showDetail(${artifact.id}); return false;" style="color: #60a5fa; text-decoration: none;">
|
||||
${escapeHtml(artifact.filename)}
|
||||
</a>
|
||||
${artifact.tags && artifact.tags.length > 0 ? `<br><div style="margin-top: 5px;">${formatTags(artifact.tags)}</div>` : ''}
|
||||
</td>
|
||||
<td>${formatDate(artifact.created_at)}</td>
|
||||
<td>${artifact.test_name || '-'}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="icon-btn" onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" title="Download">
|
||||
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="deleteArtifact(${artifact.id})" title="Delete">
|
||||
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('artifact-count').textContent = `${displayedArtifacts.length} artifacts`;
|
||||
|
||||
// Re-initialize Lucide icons for dynamically added content
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Format result badge
|
||||
function formatResult(result) {
|
||||
if (!result) return '-';
|
||||
const className = `result-badge result-${result}`;
|
||||
return `<span class="${className}">${result}</span>`;
|
||||
}
|
||||
|
||||
// Format tags
|
||||
function formatTags(tags) {
|
||||
if (!tags || tags.length === 0) return '-';
|
||||
return tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join(' ');
|
||||
}
|
||||
|
||||
// Format bytes
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Show artifact detail
|
||||
async function showDetail(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/${id}`);
|
||||
const artifact = await response.json();
|
||||
|
||||
const detailContent = document.getElementById('detail-content');
|
||||
detailContent.innerHTML = `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">ID</div>
|
||||
<div class="detail-value">${artifact.id}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Filename</div>
|
||||
<div class="detail-value">${escapeHtml(artifact.filename)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">File Type</div>
|
||||
<div class="detail-value"><span class="file-type-badge">${artifact.file_type}</span></div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Size</div>
|
||||
<div class="detail-value">${formatBytes(artifact.file_size)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Storage Path</div>
|
||||
<div class="detail-value"><code>${artifact.storage_path}</code></div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Uploaded By</div>
|
||||
<div class="detail-value">${artifact.test_name || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Sim Source</div>
|
||||
<div class="detail-value">${artifact.test_suite || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Test Result</div>
|
||||
<div class="detail-value">${formatResult(artifact.test_result)}</div>
|
||||
</div>
|
||||
${artifact.test_config ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Test Config</div>
|
||||
<div class="detail-value"><pre>${JSON.stringify(artifact.test_config, null, 2)}</pre></div>
|
||||
</div>
|
||||
` : ''}
|
||||
${artifact.custom_metadata ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Custom Metadata</div>
|
||||
<div class="detail-value"><pre>${JSON.stringify(artifact.custom_metadata, null, 2)}</pre></div>
|
||||
</div>
|
||||
` : ''}
|
||||
${artifact.description ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Description</div>
|
||||
<div class="detail-value">${escapeHtml(artifact.description)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${artifact.tags && artifact.tags.length > 0 ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Tags</div>
|
||||
<div class="detail-value">${formatTags(artifact.tags)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Version</div>
|
||||
<div class="detail-value">${artifact.version || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Created</div>
|
||||
<div class="detail-value">${formatDate(artifact.created_at)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Updated</div>
|
||||
<div class="detail-value">${formatDate(artifact.updated_at)}</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 10px;">
|
||||
<button onclick="downloadArtifact(${artifact.id}, '${escapeHtml(artifact.filename)}')" class="btn btn-primary">
|
||||
<i data-lucide="download" style="width: 16px; height: 16px;"></i> Download
|
||||
</button>
|
||||
<button onclick="deleteArtifact(${artifact.id}); closeDetailModal();" class="btn btn-danger">
|
||||
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('detail-modal').classList.add('active');
|
||||
|
||||
// Re-initialize Lucide icons for modal content
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
alert('Error loading artifact details: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close detail modal
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detail-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Download artifact
|
||||
async function downloadArtifact(id, filename) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/${id}/download`);
|
||||
const blob = await response.blob();
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
alert('Error downloading artifact: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete artifact
|
||||
async function deleteArtifact(id) {
|
||||
if (!confirm('Are you sure you want to delete this artifact? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadArtifacts((currentPage - 1) * pageSize, pageSize);
|
||||
alert('Artifact deleted successfully');
|
||||
} else {
|
||||
throw new Error('Failed to delete artifact');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error deleting artifact: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload artifact
|
||||
async function uploadArtifact(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData();
|
||||
|
||||
// Add file
|
||||
const fileInput = document.getElementById('file');
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
// Add optional fields
|
||||
const fields = ['test_name', 'test_suite', 'test_result', 'version', 'description', 'sim_source_id'];
|
||||
fields.forEach(field => {
|
||||
const value = form.elements[field]?.value;
|
||||
if (value) formData.append(field, value);
|
||||
});
|
||||
|
||||
// Add tags (convert comma-separated to JSON array)
|
||||
const tags = document.getElementById('tags').value;
|
||||
if (tags) {
|
||||
const tagsArray = tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
formData.append('tags', JSON.stringify(tagsArray));
|
||||
}
|
||||
|
||||
// Add JSON fields
|
||||
const testConfig = document.getElementById('test-config').value;
|
||||
if (testConfig) {
|
||||
try {
|
||||
JSON.parse(testConfig); // Validate
|
||||
formData.append('test_config', testConfig);
|
||||
} catch (e) {
|
||||
showUploadStatus('Invalid Test Config JSON', false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const customMetadata = document.getElementById('custom-metadata').value;
|
||||
if (customMetadata) {
|
||||
try {
|
||||
JSON.parse(customMetadata); // Validate
|
||||
formData.append('custom_metadata', customMetadata);
|
||||
} catch (e) {
|
||||
showUploadStatus('Invalid Custom Metadata JSON', false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const artifact = await response.json();
|
||||
showUploadStatus(`Successfully uploaded: ${artifact.filename}`, true);
|
||||
form.reset();
|
||||
loadArtifacts();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
showUploadStatus('Upload failed: ' + error.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Show upload status
|
||||
function showUploadStatus(message, success) {
|
||||
const status = document.getElementById('upload-status');
|
||||
status.textContent = message;
|
||||
status.className = success ? 'success' : 'error';
|
||||
|
||||
setTimeout(() => {
|
||||
status.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Query artifacts
|
||||
async function queryArtifacts(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const query = {};
|
||||
|
||||
const filename = document.getElementById('q-filename').value;
|
||||
if (filename) query.filename = filename;
|
||||
|
||||
const fileType = document.getElementById('q-type').value;
|
||||
if (fileType) query.file_type = fileType;
|
||||
|
||||
const testName = document.getElementById('q-test-name').value;
|
||||
if (testName) query.test_name = testName;
|
||||
|
||||
const suite = document.getElementById('q-suite').value;
|
||||
if (suite) query.test_suite = suite;
|
||||
|
||||
const result = document.getElementById('q-result').value;
|
||||
if (result) query.test_result = result;
|
||||
|
||||
const tags = document.getElementById('q-tags').value;
|
||||
if (tags) {
|
||||
query.tags = tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
|
||||
const startDate = document.getElementById('q-start-date').value;
|
||||
if (startDate) query.start_date = new Date(startDate).toISOString();
|
||||
|
||||
const endDate = document.getElementById('q-end-date').value;
|
||||
if (endDate) query.end_date = new Date(endDate).toISOString();
|
||||
|
||||
query.limit = 100;
|
||||
query.offset = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/artifacts/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(query)
|
||||
});
|
||||
|
||||
const artifacts = await response.json();
|
||||
|
||||
// Switch to artifacts tab and display results
|
||||
showTab('artifacts');
|
||||
displayArtifacts(artifacts);
|
||||
} catch (error) {
|
||||
alert('Query failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear query form
|
||||
function clearQuery() {
|
||||
document.getElementById('query-form').reset();
|
||||
}
|
||||
|
||||
// Generate seed data
|
||||
async function generateSeedData() {
|
||||
const count = prompt('How many artifacts to generate? (1-100)', '10');
|
||||
if (!count) return;
|
||||
|
||||
const num = parseInt(count);
|
||||
if (isNaN(num) || num < 1 || num > 100) {
|
||||
alert('Please enter a number between 1 and 100');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/seed/generate/${num}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert(result.message);
|
||||
loadArtifacts();
|
||||
} else {
|
||||
throw new Error(result.detail || 'Generation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error generating seed data: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Tab navigation
|
||||
function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
function updatePagination(count) {
|
||||
const pageInfo = document.getElementById('page-info');
|
||||
pageInfo.textContent = `Page ${currentPage}`;
|
||||
|
||||
document.getElementById('prev-btn').disabled = currentPage === 1;
|
||||
document.getElementById('next-btn').disabled = count < pageSize;
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
currentPage++;
|
||||
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
|
||||
}
|
||||
|
||||
// Auto-refresh functions
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
}
|
||||
|
||||
if (autoRefreshEnabled) {
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
// Only refresh if on the artifacts tab
|
||||
const artifactsTab = document.getElementById('artifacts-tab');
|
||||
if (artifactsTab && artifactsTab.classList.contains('active')) {
|
||||
loadArtifacts(pageSize, (currentPage - 1) * pageSize);
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshEnabled = !autoRefreshEnabled;
|
||||
|
||||
const toggleBtn = document.getElementById('auto-refresh-toggle');
|
||||
if (autoRefreshEnabled) {
|
||||
toggleBtn.textContent = 'Auto-refresh: ON';
|
||||
toggleBtn.classList.remove('btn-secondary');
|
||||
toggleBtn.classList.add('btn-success');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
toggleBtn.textContent = 'Auto-refresh: OFF';
|
||||
toggleBtn.classList.remove('btn-success');
|
||||
toggleBtn.classList.add('btn-secondary');
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sorting to artifacts array
|
||||
function applySorting(artifacts) {
|
||||
if (!currentSortColumn) return artifacts;
|
||||
|
||||
return artifacts.sort((a, b) => {
|
||||
let aVal = a[currentSortColumn] || '';
|
||||
let bVal = b[currentSortColumn] || '';
|
||||
|
||||
// Handle date sorting
|
||||
if (currentSortColumn === 'created_at') {
|
||||
aVal = new Date(aVal).getTime();
|
||||
bVal = new Date(bVal).getTime();
|
||||
} else {
|
||||
// String comparison (case insensitive)
|
||||
aVal = String(aVal).toLowerCase();
|
||||
bVal = String(bVal).toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Sorting functionality
|
||||
function sortTable(column) {
|
||||
// Toggle sort direction if clicking same column
|
||||
if (currentSortColumn === column) {
|
||||
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSortColumn = column;
|
||||
currentSortDirection = 'asc';
|
||||
}
|
||||
|
||||
// Update sort indicators
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
});
|
||||
|
||||
const sortedHeader = event.target.closest('th');
|
||||
sortedHeader.classList.add(`sort-${currentSortDirection}`);
|
||||
|
||||
// Apply filter and sort
|
||||
filterTable();
|
||||
}
|
||||
|
||||
// Filtering functionality - searches across all columns
|
||||
function filterTable() {
|
||||
const searchTerm = document.getElementById('filter-search').value.toLowerCase();
|
||||
|
||||
const filteredArtifacts = allArtifacts.filter(artifact => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
// Search across all relevant fields
|
||||
const searchableText = [
|
||||
artifact.test_suite || '',
|
||||
artifact.filename || '',
|
||||
artifact.test_name || '',
|
||||
formatDate(artifact.created_at)
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return searchableText.includes(searchTerm);
|
||||
});
|
||||
|
||||
displayArtifacts(filteredArtifacts);
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-search').value = '';
|
||||
filterTable();
|
||||
}
|
||||
Reference in New Issue
Block a user