Compare commits

..

4 Commits

Author SHA1 Message Date
f1403b1fc0 Merge pull request 'f/app' (#2) from f/app into main
Reviewed-on: mondo/SIM-Data-Platform#2
2025-10-16 13:57:24 -05:00
pratik
26328fec6a Update MR 2025-10-16 13:49:12 -05:00
pratik
14d85614ed Update gitignore, combined docker for frotnend and api 2025-10-16 13:38:11 -05:00
pratik
7c1346caac Update build process, gitignore packagelock: 2025-10-16 13:02:50 -05:00
24 changed files with 2296 additions and 4482 deletions

7
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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;"]

View File

@@ -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;"]

View File

@@ -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

View File

@@ -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"""

29
build-for-airgap.sh Executable file → Normal file
View File

@@ -15,7 +15,7 @@ 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."
echo "Error: Node.js is not installed. Please install Node.js 24+ first."
exit 1
fi
@@ -34,30 +34,37 @@ echo "Step 2/3: Building Angular production bundle..."
npm run build:prod
echo ""
echo "Step 3/3: Verifying build output..."
echo "Step 3/3: Copying to static directory..."
if [ -d "dist/frontend/browser" ]; then
echo "✓ Build successful!"
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
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 "The Angular app has been built and copied to static/"
echo "You can now:"
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 will rebuild Angular during build)"
echo ""
echo "See DEPLOYMENT.md for more details."
echo "========================================="

View File

@@ -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:

View File

View File

@@ -93,5 +93,8 @@
}
}
}
},
"cli": {
"analytics": false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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";
}
}

View File

@@ -46,8 +46,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"

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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;
}
}

View File

@@ -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()">&times;</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>

View File

@@ -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();
}