diff --git a/Dockerfile b/Dockerfile
index 2b3de52..ea95f18 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,8 +14,10 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
+COPY utils/ ./utils/
COPY alembic/ ./alembic/
COPY alembic.ini .
+COPY static/ ./static/
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
diff --git a/app/api/seed.py b/app/api/seed.py
new file mode 100644
index 0000000..c8777d6
--- /dev/null
+++ b/app/api/seed.py
@@ -0,0 +1,38 @@
+from fastapi import APIRouter, HTTPException
+from app.database import SessionLocal
+import asyncio
+import sys
+import os
+
+# Add parent directory to path
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
+
+from utils.seed_data import generate_seed_data as generate_data, clear_all_data
+
+router = APIRouter(prefix="/api/v1/seed", tags=["seed"])
+
+
+@router.post("/generate/{count}")
+async def generate_seed_data(count: int = 10):
+ """Generate seed data"""
+ if count < 1 or count > 100:
+ raise HTTPException(status_code=400, detail="Count must be between 1 and 100")
+
+ try:
+ artifact_ids = await generate_data(count)
+ return {
+ "message": f"Successfully generated {len(artifact_ids)} artifacts",
+ "artifact_ids": artifact_ids
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Seed generation failed: {str(e)}")
+
+
+@router.delete("/clear")
+async def clear_data():
+ """Clear all data"""
+ try:
+ await clear_all_data()
+ return {"message": "All data cleared successfully"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
diff --git a/app/main.py b/app/main.py
index 09dc6d7..c5d3770 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,9 +1,13 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
from app.api.artifacts import router as artifacts_router
+from app.api.seed import router as seed_router
from app.database import init_db
from app.config import settings
import logging
+import os
# Configure logging
logging.basicConfig(
@@ -33,6 +37,12 @@ app.add_middleware(
# Include routers
app.include_router(artifacts_router)
+app.include_router(seed_router)
+
+# Mount static files
+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")
@@ -45,9 +55,9 @@ async def startup_event():
logger.info("Application started successfully")
-@app.get("/")
-async def root():
- """Root endpoint"""
+@app.get("/api")
+async def api_root():
+ """API root endpoint"""
return {
"message": "Test Artifact Data Lake API",
"version": "1.0.0",
@@ -57,6 +67,23 @@ async def root():
}
+@app.get("/")
+async def ui_root():
+ """Serve the UI"""
+ index_path = os.path.join(static_dir, "index.html")
+ if os.path.exists(index_path):
+ return FileResponse(index_path)
+ else:
+ return {
+ "message": "Test Artifact Data Lake API",
+ "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"""
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..2a73e1e
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,436 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ padding: 20px;
+}
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ overflow: hidden;
+}
+
+header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 30px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+header h1 {
+ font-size: 28px;
+ font-weight: 600;
+}
+
+.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: #f7f9fc;
+ border-bottom: 2px solid #e2e8f0;
+}
+
+.tab-button {
+ flex: 1;
+ padding: 16px 24px;
+ background: none;
+ border: none;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s;
+ color: #64748b;
+}
+
+.tab-button:hover {
+ background: #e2e8f0;
+}
+
+.tab-button.active {
+ background: white;
+ color: #667eea;
+ border-bottom: 3px solid #667eea;
+}
+
+.tab-content {
+ display: none;
+ padding: 30px;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+.toolbar {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+ align-items: center;
+}
+
+.btn {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.btn-primary {
+ background: #667eea;
+ color: white;
+}
+
+.btn-primary:hover {
+ background: #5568d3;
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: #e2e8f0;
+ color: #475569;
+}
+
+.btn-secondary:hover {
+ background: #cbd5e1;
+}
+
+.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: #f0f9ff;
+ color: #0369a1;
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 13px;
+ font-weight: 600;
+ margin-left: auto;
+}
+
+.table-container {
+ overflow-x: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+
+thead {
+ background: #f7f9fc;
+}
+
+th {
+ padding: 14px 12px;
+ text-align: left;
+ font-weight: 600;
+ color: #475569;
+ border-bottom: 2px solid #e2e8f0;
+ white-space: nowrap;
+}
+
+td {
+ padding: 12px;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+tbody tr:hover {
+ background: #f7f9fc;
+}
+
+.loading {
+ text-align: center;
+ color: #94a3b8;
+ padding: 40px !important;
+}
+
+.result-badge {
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.result-pass {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.result-fail {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.result-skip {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.result-error {
+ background: #fecaca;
+ color: #7f1d1d;
+}
+
+.tag {
+ display: inline-block;
+ background: #e0e7ff;
+ color: #3730a3;
+ padding: 3px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ margin: 2px;
+}
+
+.file-type-badge {
+ background: #dbeafe;
+ color: #1e40af;
+ 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: #64748b;
+}
+
+.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: #475569;
+ 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 #e2e8f0;
+ border-radius: 6px;
+ font-size: 14px;
+ font-family: inherit;
+ transition: border-color 0.3s;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+small {
+ color: #94a3b8;
+ font-size: 12px;
+ display: block;
+ margin-top: 4px;
+}
+
+#upload-status {
+ margin-top: 20px;
+ padding: 14px;
+ border-radius: 6px;
+ display: none;
+}
+
+#upload-status.success {
+ background: #d1fae5;
+ color: #065f46;
+ display: block;
+}
+
+#upload-status.error {
+ background: #fee2e2;
+ color: #991b1b;
+ display: block;
+}
+
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+}
+
+.modal.active {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-content {
+ background: white;
+ 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.3);
+}
+
+.close {
+ position: absolute;
+ right: 20px;
+ top: 20px;
+ font-size: 28px;
+ font-weight: bold;
+ color: #94a3b8;
+ cursor: pointer;
+ transition: color 0.3s;
+}
+
+.close:hover {
+ color: #475569;
+}
+
+.detail-row {
+ margin-bottom: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.detail-row:last-child {
+ border-bottom: none;
+}
+
+.detail-label {
+ font-weight: 600;
+ color: #475569;
+ margin-bottom: 4px;
+}
+
+.detail-value {
+ color: #64748b;
+}
+
+pre {
+ background: #f7f9fc;
+ padding: 12px;
+ border-radius: 6px;
+ overflow-x: auto;
+ font-size: 12px;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+.icon-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 18px;
+ padding: 6px;
+ border-radius: 4px;
+ transition: background 0.3s;
+}
+
+.icon-btn:hover {
+ background: #e2e8f0;
+}
+
+@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;
+ }
+}
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..4fcd19c
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,208 @@
+
+
+
+
+
+ Test Artifact Data Lake
+
+
+
+
+
+ 🗄️ Test Artifact Data Lake
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Filename |
+ Type |
+ Size |
+ Test Name |
+ Suite |
+ Result |
+ Tags |
+ Created |
+ Actions |
+
+
+
+
+ | Loading artifacts... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Query Artifacts
+
+
+
+
+
+
+
+
×
+
Artifact Details
+
+
+
+
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js
new file mode 100644
index 0000000..c7ff00a
--- /dev/null
+++ b/static/js/app.js
@@ -0,0 +1,462 @@
+// API Base URL
+const API_BASE = '/api/v1';
+
+// Pagination
+let currentPage = 1;
+let pageSize = 25;
+let totalArtifacts = 0;
+
+// Load API info on page load
+window.addEventListener('DOMContentLoaded', () => {
+ loadApiInfo();
+ loadArtifacts();
+});
+
+// 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();
+
+ displayArtifacts(artifacts);
+ updatePagination(artifacts.length);
+ } catch (error) {
+ console.error('Error loading artifacts:', error);
+ document.getElementById('artifacts-tbody').innerHTML = `
+ |
+ Error loading artifacts: ${error.message}
+ |
+ `;
+ }
+}
+
+// Display artifacts in table
+function displayArtifacts(artifacts) {
+ const tbody = document.getElementById('artifacts-tbody');
+
+ if (artifacts.length === 0) {
+ tbody.innerHTML = '| No artifacts found. Upload some files to get started! |
';
+ document.getElementById('artifact-count').textContent = '0 artifacts';
+ return;
+ }
+
+ tbody.innerHTML = artifacts.map(artifact => `
+
+ | ${artifact.id} |
+
+
+ ${escapeHtml(artifact.filename)}
+
+ |
+ ${artifact.file_type} |
+ ${formatBytes(artifact.file_size)} |
+ ${artifact.test_name || '-'} |
+ ${artifact.test_suite || '-'} |
+ ${formatResult(artifact.test_result)} |
+ ${formatTags(artifact.tags)} |
+ ${formatDate(artifact.created_at)} |
+
+
+
+
+
+ |
+
+ `).join('');
+
+ document.getElementById('artifact-count').textContent = `${artifacts.length} artifacts`;
+}
+
+// Format result badge
+function formatResult(result) {
+ if (!result) return '-';
+ const className = `result-badge result-${result}`;
+ return `${result}`;
+}
+
+// Format tags
+function formatTags(tags) {
+ if (!tags || tags.length === 0) return '-';
+ return tags.map(tag => `${escapeHtml(tag)}`).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 = `
+
+
+
Filename
+
${escapeHtml(artifact.filename)}
+
+
+
File Type
+
${artifact.file_type}
+
+
+
Size
+
${formatBytes(artifact.file_size)}
+
+
+
Storage Path
+
${artifact.storage_path}
+
+
+
Test Name
+
${artifact.test_name || '-'}
+
+
+
Test Suite
+
${artifact.test_suite || '-'}
+
+
+
Test Result
+
${formatResult(artifact.test_result)}
+
+ ${artifact.test_config ? `
+
+
Test Config
+
${JSON.stringify(artifact.test_config, null, 2)}
+
+ ` : ''}
+ ${artifact.custom_metadata ? `
+
+
Custom Metadata
+
${JSON.stringify(artifact.custom_metadata, null, 2)}
+
+ ` : ''}
+ ${artifact.description ? `
+
+
Description
+
${escapeHtml(artifact.description)}
+
+ ` : ''}
+ ${artifact.tags && artifact.tags.length > 0 ? `
+
+
Tags
+
${formatTags(artifact.tags)}
+
+ ` : ''}
+
+
Version
+
${artifact.version || '-'}
+
+
+
Created
+
${formatDate(artifact.created_at)}
+
+
+
Updated
+
${formatDate(artifact.updated_at)}
+
+
+
+
+
+ `;
+
+ document.getElementById('detail-modal').classList.add('active');
+ } 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'];
+ 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);
+}