chore: remove obsolete payment architecture and integration test documentation

- Remove AITBC_PAYMENT_ARCHITECTURE.md (dual-currency system documentation)
- Remove IMPLEMENTATION_COMPLETE_SUMMARY.md (integration test completion summary)
- Remove INTEGRATION_TEST_FIXES.md (test fixes documentation)
- Remove INTEGRATION_TEST_UPDATES.md (real features implementation notes)
- Remove PAYMENT_INTEGRATION_COMPLETE.md (wallet-coordinator integration docs)
- Remove WALLET_COORDINATOR_INTEGRATION.md (payment
This commit is contained in:
oib
2026-01-29 12:28:43 +01:00
parent 5c99c92ffb
commit ff4554b9dd
94 changed files with 7925 additions and 128 deletions

View File

@@ -0,0 +1,195 @@
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
const API_BASE = '/api/v1'
const app = createApp({
data() {
return {
loading: true,
chainInfo: {
height: 0,
hash: '',
timestamp: null,
tx_count: 0
},
latestBlocks: [],
stats: {
totalBlocks: 0,
totalTransactions: 0,
avgBlockTime: 2.0,
hashRate: '0 H/s'
},
error: null
}
},
async mounted() {
await this.loadChainData()
setInterval(this.loadChainData, 5000)
},
methods: {
async loadChainData() {
try {
this.error = null
// Load chain head
const headResponse = await fetch(`${API_BASE}/chain/head`)
if (headResponse.ok) {
this.chainInfo = await headResponse.json()
}
// Load latest blocks
const blocksResponse = await fetch(`${API_BASE}/chain/blocks?limit=10`)
if (blocksResponse.ok) {
this.latestBlocks = await blocksResponse.json()
}
// Calculate stats
this.stats.totalBlocks = this.chainInfo.height || 0
this.stats.totalTransactions = this.latestBlocks.reduce((sum, block) => sum + (block.tx_count || 0), 0)
this.loading = false
} catch (error) {
console.error('Failed to load chain data:', error)
this.error = 'Failed to connect to blockchain node'
this.loading = false
}
},
formatHash(hash) {
if (!hash) return '-'
return hash.substring(0, 10) + '...' + hash.substring(hash.length - 8)
},
formatTime(timestamp) {
if (!timestamp) return '-'
return new Date(timestamp * 1000).toLocaleString()
},
formatNumber(num) {
if (!num) return '0'
return num.toLocaleString()
},
getBlockType(block) {
if (!block) return 'unknown'
return block.tx_count > 0 ? 'with-tx' : 'empty'
}
},
template: `
<div class="app">
<!-- Header -->
<header class="header">
<div class="container">
<div class="header-content">
<div class="logo">
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 2L2 7L12 12L22 7L12 2Z"></path>
<path d="M2 17L12 22L22 17"></path>
<path d="M2 12L12 17L22 12"></path>
</svg>
<h1>AITBC Explorer</h1>
</div>
<div class="header-stats">
<div class="stat">
<span class="stat-label">Height</span>
<span class="stat-value">{{ formatNumber(chainInfo.height) }}</span>
</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main">
<div class="container">
<!-- Loading State -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>Loading blockchain data...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<p>{{ error }}</p>
<button @click="loadChainData" class="retry-btn">Retry</button>
</div>
<!-- Chain Overview -->
<div v-else class="overview">
<div class="cards">
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
</div>
<div class="card-content">
<h3>Current Height</h3>
<p class="card-value">{{ formatNumber(chainInfo.height) }}</p>
</div>
</div>
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
</svg>
</div>
<div class="card-content">
<h3>Latest Block</h3>
<p class="card-value hash">{{ formatHash(chainInfo.hash) }}</p>
</div>
</div>
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="card-content">
<h3>Total Transactions</h3>
<p class="card-value">{{ formatNumber(stats.totalTransactions) }}</p>
</div>
</div>
</div>
</div>
<!-- Latest Blocks -->
<div v-if="!loading && !error" class="blocks-section">
<h2>Latest Blocks</h2>
<div class="blocks-list">
<div v-for="block in latestBlocks" :key="block.height"
class="block-item" :class="getBlockType(block)">
<div class="block-height">
<span class="height">{{ formatNumber(block.height) }}</span>
<span v-if="block.tx_count > 0" class="tx-badge">{{ block.tx_count }} tx</span>
</div>
<div class="block-details">
<div class="block-hash">
<span class="label">Hash:</span>
<span class="value">{{ formatHash(block.hash) }}</span>
</div>
<div class="block-time">
<span class="label">Time:</span>
<span class="value">{{ formatTime(block.timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
`
})
app.mount('#app')

View File

@@ -0,0 +1,315 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #3b82f6;
--primary-dark: #2563eb;
--secondary-color: #10b981;
--background: #f9fafb;
--surface: #ffffff;
--text-primary: #111827;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--error-color: #ef4444;
--success-color: #22c55e;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--background);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Header */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 2rem;
height: 2rem;
color: var(--primary-color);
}
.logo h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.header-stats {
display: flex;
gap: 2rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
/* Main Content */
.main {
padding: 2rem 0;
min-height: calc(100vh - 80px);
}
/* Loading State */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 0;
color: var(--text-secondary);
}
.spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error State */
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 0;
color: var(--error-color);
}
.error-icon {
width: 3rem;
height: 3rem;
margin-bottom: 1rem;
}
.retry-btn {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-btn:hover {
background: var(--primary-dark);
}
/* Overview Cards */
.overview {
margin-bottom: 2rem;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.card {
background: var(--surface);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-icon {
width: 3rem;
height: 3rem;
background: var(--primary-color);
color: white;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon svg {
width: 1.5rem;
height: 1.5rem;
}
.card-content h3 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.card-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.card-value.hash {
font-family: 'Courier New', monospace;
font-size: 1rem;
}
/* Blocks Section */
.blocks-section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.blocks-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.block-item {
background: var(--surface);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.block-item:hover {
transform: translateX(4px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.block-item.empty {
opacity: 0.7;
}
.block-height {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.height {
font-size: 1.25rem;
font-weight: 600;
color: var(--primary-color);
}
.tx-badge {
margin-top: 0.25rem;
padding: 0.125rem 0.5rem;
background: var(--success-color);
color: white;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.block-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.block-hash,
.block-time {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.label {
color: var(--text-secondary);
font-weight: 500;
}
.value {
color: var(--text-primary);
font-family: 'Courier New', monospace;
}
/* Responsive Design */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 1rem;
}
.cards {
grid-template-columns: 1fr;
}
.block-item {
flex-direction: column;
align-items: flex-start;
}
.block-height {
flex-direction: row;
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AITBC Explorer</title>
<script type="module" crossorigin src="/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/assets/style.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
AITBC Blockchain Explorer
A simple web interface to explore the blockchain
"""
import asyncio
import httpx
import json
from datetime import datetime
from typing import Dict, List, Optional, Any
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
app = FastAPI(title="AITBC Blockchain Explorer", version="1.0.0")
# Configuration
BLOCKCHAIN_RPC_URL = "http://localhost:8082" # Local blockchain node
EXTERNAL_RPC_URL = "http://aitbc.keisanki.net:8082" # External access
# HTML Template
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AITBC Blockchain Explorer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.fade-in { animation: fadeIn 0.3s ease-in; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-blue-600 text-white shadow-lg">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i data-lucide="cube" class="w-8 h-8"></i>
<h1 class="text-2xl font-bold">AITBC Blockchain Explorer</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm">Network: <span class="font-mono bg-blue-700 px-2 py-1 rounded">ait-devnet</span></span>
<button onclick="refreshData()" class="bg-blue-500 hover:bg-blue-400 px-3 py-1 rounded flex items-center space-x-1">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
<span>Refresh</span>
</button>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<!-- Chain Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Current Height</p>
<p class="text-2xl font-bold" id="chain-height">-</p>
</div>
<i data-lucide="trending-up" class="w-10 h-10 text-green-500"></i>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Latest Block</p>
<p class="text-lg font-mono" id="latest-hash">-</p>
</div>
<i data-lucide="hash" class="w-10 h-10 text-blue-500"></i>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Node Status</p>
<p class="text-lg font-semibold" id="node-status">-</p>
</div>
<i data-lucide="activity" class="w-10 h-10 text-purple-500"></i>
</div>
</div>
</div>
<!-- Search -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex space-x-4">
<input type="text" id="search-input" placeholder="Search by block height, hash, or transaction hash"
class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<button onclick="search()" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700">
Search
</button>
</div>
</div>
<!-- Latest Blocks -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold flex items-center">
<i data-lucide="blocks" class="w-5 h-5 mr-2"></i>
Latest Blocks
</h2>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-left text-gray-500 text-sm">
<th class="pb-3">Height</th>
<th class="pb-3">Hash</th>
<th class="pb-3">Timestamp</th>
<th class="pb-3">Transactions</th>
<th class="pb-3">Actions</th>
</tr>
</thead>
<tbody id="blocks-table">
<tr>
<td colspan="5" class="text-center py-8 text-gray-500">
Loading blocks...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Block Details Modal -->
<div id="block-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">Block Details</h2>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
</div>
<div class="p-6" id="block-details">
<!-- Block details will be loaded here -->
</div>
</div>
</div>
</div>
</main>
<footer class="bg-gray-800 text-white mt-12">
<div class="container mx-auto px-4 py-6 text-center">
<p class="text-sm">AITBC Blockchain Explorer - Connected to node at {node_url}</p>
</div>
</footer>
<script>
// Initialize lucide icons
lucide.createIcons();
// Global state
let currentData = {};
// Load initial data
document.addEventListener('DOMContentLoaded', () => {
refreshData();
});
// Refresh all data
async function refreshData() {
try {
await Promise.all([
loadChainStats(),
loadLatestBlocks()
]);
} catch (error) {
console.error('Error refreshing data:', error);
document.getElementById('node-status').innerHTML = '<span class="text-red-500">Error</span>';
}
}
// Load chain statistics
async function loadChainStats() {
const response = await fetch('/api/chain/head');
const data = await response.json();
document.getElementById('chain-height').textContent = data.height || '-';
document.getElementById('latest-hash').textContent = data.hash ? data.hash.substring(0, 16) + '...' : '-';
document.getElementById('node-status').innerHTML = '<span class="text-green-500">Online</span>';
currentData.head = data;
}
// Load latest blocks
async function loadLatestBlocks() {
const tbody = document.getElementById('blocks-table');
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-8 text-gray-500">Loading blocks...</td></tr>';
const head = await fetch('/api/chain/head').then(r => r.json());
const blocks = [];
// Load last 10 blocks
for (let i = 0; i < 10 && head.height - i >= 0; i++) {
const block = await fetch(`/api/blocks/${head.height - i}`).then(r => r.json());
blocks.push(block);
}
tbody.innerHTML = blocks.map(block => `
<tr class="border-t hover:bg-gray-50">
<td class="py-3 font-mono">${block.height}</td>
<td class="py-3 font-mono text-sm">${block.hash ? block.hash.substring(0, 16) + '...' : '-'}</td>
<td class="py-3 text-sm">${formatTimestamp(block.timestamp)}</td>
<td class="py-3">${block.transactions ? block.transactions.length : 0}</td>
<td class="py-3">
<button onclick="showBlockDetails(${block.height})" class="text-blue-600 hover:text-blue-800">
View Details
</button>
</td>
</tr>
`).join('');
}
// Show block details
async function showBlockDetails(height) {
const block = await fetch(`/api/blocks/${height}`).then(r => r.json());
const modal = document.getElementById('block-modal');
const details = document.getElementById('block-details');
details.innerHTML = `
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold mb-2">Block Header</h3>
<div class="bg-gray-50 rounded p-4 space-y-2">
<div class="flex justify-between">
<span class="text-gray-600">Height:</span>
<span class="font-mono">${block.height}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Hash:</span>
<span class="font-mono text-sm">${block.hash || '-'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Parent Hash:</span>
<span class="font-mono text-sm">${block.parent_hash || '-'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Timestamp:</span>
<span>${formatTimestamp(block.timestamp)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Proposer:</span>
<span class="font-mono text-sm">${block.proposer || '-'}</span>
</div>
</div>
</div>
${block.transactions && block.transactions.length > 0 ? `
<div>
<h3 class="text-lg font-semibold mb-2">Transactions (${block.transactions.length})</h3>
<div class="space-y-2">
${block.transactions.map(tx => `
<div class="bg-gray-50 rounded p-4">
<div class="flex justify-between mb-2">
<span class="text-gray-600">Hash:</span>
<span class="font-mono text-sm">${tx.hash || '-'}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-gray-600">Type:</span>
<span>${tx.type || '-'}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-gray-600">From:</span>
<span class="font-mono text-sm">${tx.sender || '-'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Fee:</span>
<span>${tx.fee || '0'}</span>
</div>
</div>
`).join('')}
</div>
</div>
` : '<p class="text-gray-500">No transactions in this block</p>'}
</div>
`;
modal.classList.remove('hidden');
}
// Close modal
function closeModal() {
document.getElementById('block-modal').classList.add('hidden');
}
// Search functionality
async function search() {
const query = document.getElementById('search-input').value.trim();
if (!query) return;
// Try block height first
if (/^\\d+$/.test(query)) {
showBlockDetails(parseInt(query));
return;
}
// TODO: Add transaction hash search
alert('Search by block height is currently supported');
}
// Format timestamp
function formatTimestamp(timestamp) {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
}
// Auto-refresh every 30 seconds
setInterval(refreshData, 30000);
</script>
</body>
</html>
"""
async def get_chain_head() -> Dict[str, Any]:
"""Get the current chain head"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/head")
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting chain head: {e}")
return {}
async def get_block(height: int) -> Dict[str, Any]:
"""Get a specific block by height"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/blocks/{height}")
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting block {height}: {e}")
return {}
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve the explorer UI"""
return HTML_TEMPLATE.format(node_url=BLOCKCHAIN_RPC_URL)
@app.get("/api/chain/head")
async def api_chain_head():
"""API endpoint for chain head"""
return await get_chain_head()
@app.get("/api/blocks/{height}")
async def api_block(height: int):
"""API endpoint for block data"""
return await get_block(height)
@app.get("/health")
async def health():
"""Health check endpoint"""
head = await get_chain_head()
return {
"status": "ok" if head else "error",
"node_url": BLOCKCHAIN_RPC_URL,
"chain_height": head.get("height", 0)
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=3000)

View File

@@ -0,0 +1,31 @@
server {
listen 3000;
server_name _;
root /opt/blockchain-explorer;
index index.html;
# API proxy - standardize endpoints
location /api/v1/ {
proxy_pass http://localhost:8082/rpc/;
proxy_set_header Host $host;
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;
}
# Legacy API compatibility
location /rpc {
return 307 /api/v1$uri?$args;
}
# Static files
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,3 @@
fastapi==0.111.1
uvicorn[standard]==0.30.6
httpx==0.27.2