Merge pull request 'f/app' (#2) from f/app into main
Reviewed-on: mondo/SIM-Data-Platform#2
This commit is contained in:
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/
|
||||||
|
|||||||
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
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -20,7 +47,9 @@ COPY app/ ./app/
|
|||||||
COPY utils/ ./utils/
|
COPY utils/ ./utils/
|
||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY alembic.ini .
|
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)
|
# Create non-root user (Alpine uses adduser instead of useradd)
|
||||||
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
|
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
|
.\quickstart.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows (Command Prompt):**
|
|
||||||
```batch
|
|
||||||
quickstart.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
### Air-Gapped/Restricted Environment Deployment
|
### Air-Gapped/Restricted Environment Deployment
|
||||||
|
|
||||||
**For environments with restricted npm access:**
|
**For environments with restricted npm access:**
|
||||||
@@ -65,7 +60,7 @@ This script:
|
|||||||
2. Packages pre-built files into Docker
|
2. Packages pre-built files into Docker
|
||||||
3. Starts all services
|
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
|
### 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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -39,10 +39,8 @@ app.add_middleware(
|
|||||||
app.include_router(artifacts_router)
|
app.include_router(artifacts_router)
|
||||||
app.include_router(seed_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")
|
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")
|
@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")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
|
|||||||
29
build-for-airgap.sh
Executable file → Normal file
29
build-for-airgap.sh
Executable file → Normal file
@@ -15,7 +15,7 @@ fi
|
|||||||
|
|
||||||
# Check if node is installed
|
# Check if node is installed
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "Error: Node.js is not installed. Please install Node.js 18+ first."
|
echo "Error: Node.js is not installed. Please install Node.js 24+ first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -34,30 +34,37 @@ echo "Step 2/3: Building Angular production bundle..."
|
|||||||
npm run build:prod
|
npm run build:prod
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Step 3/3: Verifying build output..."
|
echo "Step 3/3: Copying to static directory..."
|
||||||
if [ -d "dist/frontend/browser" ]; then
|
if [ -d "dist/frontend/browser" ]; then
|
||||||
echo "✓ Build successful!"
|
echo "✓ Build successful!"
|
||||||
echo "✓ Output: frontend/dist/frontend/browser"
|
echo "✓ Output: frontend/dist/frontend/browser"
|
||||||
ls -lh dist/frontend/browser | head -5
|
|
||||||
|
# Copy to static directory for local FastAPI serving
|
||||||
|
cd ..
|
||||||
|
rm -rf static/*
|
||||||
|
cp -r frontend/dist/frontend/browser/* static/
|
||||||
|
echo "✓ Copied to static/ directory"
|
||||||
|
|
||||||
|
ls -lh static/ | head -10
|
||||||
else
|
else
|
||||||
echo "✗ Build failed - output directory not found"
|
echo "✗ Build failed - output directory not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo "Build Complete!"
|
echo "Build Complete!"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "The Angular app has been built and copied to static/"
|
||||||
echo "1. Update docker-compose.yml:"
|
echo "You can now:"
|
||||||
echo " Change: dockerfile: Dockerfile.frontend"
|
|
||||||
echo " To: dockerfile: Dockerfile.frontend.prebuilt"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "2. Deploy:"
|
echo "1. Run locally with FastAPI:"
|
||||||
|
echo " uvicorn app.main:app --reload"
|
||||||
|
echo " Access at: http://localhost:8000"
|
||||||
|
echo ""
|
||||||
|
echo "2. Deploy with Docker:"
|
||||||
echo " docker-compose up -d --build"
|
echo " docker-compose up -d --build"
|
||||||
|
echo " (Docker will rebuild Angular during build)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "See DEPLOYMENT.md for more details."
|
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
api:
|
app:
|
||||||
|
container_name: warehouse13-app
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -59,20 +60,6 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -93,5 +93,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,8 +46,7 @@ echo "========================================="
|
|||||||
echo "Services are running!"
|
echo "Services are running!"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Frontend: http://localhost:4200"
|
echo "Web UI: http://localhost:8000"
|
||||||
echo "API: http://localhost:8000"
|
|
||||||
echo "API Docs: http://localhost:8000/docs"
|
echo "API Docs: http://localhost:8000/docs"
|
||||||
echo "MinIO Console: http://localhost:9001"
|
echo "MinIO Console: http://localhost:9001"
|
||||||
echo " Username: minioadmin"
|
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 " Generate seed data: " -NoNewline
|
||||||
Write-Host "Use the 'Generate Seed Data' button in the UI" -ForegroundColor Yellow
|
Write-Host "Use the 'Generate Seed Data' button in the UI" -ForegroundColor Yellow
|
||||||
Write-Host " View logs: " -NoNewline
|
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 " Restart services: " -NoNewline
|
||||||
Write-Host "$composeCmd restart" -ForegroundColor Yellow
|
Write-Host "$composeCmd restart" -ForegroundColor Yellow
|
||||||
Write-Host " Stop all: " -NoNewline
|
Write-Host " Stop all: " -NoNewline
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ echo "========================================="
|
|||||||
echo "Services are running!"
|
echo "Services are running!"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "API: http://localhost:8000"
|
echo "Web UI: http://localhost:8000"
|
||||||
echo "API Docs: http://localhost:8000/docs"
|
echo "API Docs: http://localhost:8000/docs"
|
||||||
echo "MinIO Console: http://localhost:9001"
|
echo "MinIO Console: http://localhost:9001"
|
||||||
echo " Username: minioadmin"
|
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