Compare commits
19 Commits
1255e67765
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f60fbf655 | |||
| f68fb10bac | |||
| c1b7d29eb8 | |||
| ce3505cd33 | |||
| e97397ddbf | |||
| 6ed3de2749 | |||
| 3e7120c041 | |||
| 784f4bdbfb | |||
| 28e1a8775f | |||
| fe8ff44d3f | |||
| d5d630a816 | |||
| 60022ed214 | |||
| 90bbb1860d | |||
| 3e4d545e01 | |||
| e4290ef3c6 | |||
| 4de9339163 | |||
| bdb9262f4e | |||
| 8bd678c7c7 | |||
| 24c1cce542 |
+177
-160
@@ -2,193 +2,210 @@ name: Deploy to Production
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-prod-main
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEPLOY_HOST: 172.17.0.1
|
DEPLOY_HOST: 178.104.200.7
|
||||||
DEPLOY_USER: kjh2064
|
DEPLOY_USER: kjh2064
|
||||||
DEPLOY_PATH: /home/kjh2064/quantengine_active
|
|
||||||
SERVICE_NAME: quantengine
|
SERVICE_NAME: quantengine
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
TELEGRAM_BOT_TOKEN_DEFAULT: "8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-h1ZdV0"
|
QUANTENGINE_DB_NAME: quantenginedb
|
||||||
|
QUANTENGINE_DB_USER: quantengine_app
|
||||||
|
TELEGRAM_BOT_TOKEN_DEFAULT: "8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-H1ZdV0"
|
||||||
TELEGRAM_CHAT_ID_DEFAULT: "-5460205872"
|
TELEGRAM_CHAT_ID_DEFAULT: "-5460205872"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
name: Build & Deploy to Production
|
name: Build & Deploy to Production
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v3
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
|
|
||||||
- name: Install Python Dependencies
|
- name: Install Python Dependencies
|
||||||
run: pip install pyyaml openpyxl requests
|
run: pip install pyyaml openpyxl requests
|
||||||
|
|
||||||
- name: "[GATE] Run Core Validations"
|
- name: "[GATE] Run Core Validations"
|
||||||
run: |
|
run: |
|
||||||
echo "🔐 Running critical CI validations..."
|
echo "🔐 Running critical CI validations..."
|
||||||
python3 tools/validate_no_direct_api_trading_v1.py || exit 1
|
python3 tools/validate_no_direct_api_trading_v1.py || exit 1
|
||||||
python3 tools/validate_specs.py || exit 1
|
python3 tools/validate_specs.py || exit 1
|
||||||
echo "✅ All critical validations passed"
|
echo "✅ All critical validations passed"
|
||||||
|
|
||||||
- name: Ensure Temp Directory and Mock Packet
|
- name: Ensure Temp Directory and Mock Packet
|
||||||
run: |
|
run: |
|
||||||
mkdir -p Temp
|
mkdir -p Temp
|
||||||
# 빈 패킷 객체를 생성하여 dotnet test/run 시 IO Exception 방어
|
if [ ! -f Temp/final_decision_packet_active.json ]; then
|
||||||
if [ ! -f Temp/final_decision_packet_active.json ]; then
|
echo '{"active_decision": "PASS", "details": "CI dummy packet"}' > Temp/final_decision_packet_active.json
|
||||||
echo '{"active_decision": "PASS", "details": "CI dummy packet"}' > Temp/final_decision_packet_active.json
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Restore Dependencies
|
- name: Restore Dependencies
|
||||||
run: dotnet restore src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj
|
run: dotnet restore src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj
|
||||||
|
|
||||||
- name: Build Release
|
- name: Build Release
|
||||||
run: |
|
run: |
|
||||||
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
|
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
--no-restore \
|
--no-restore
|
||||||
-p:Version=1.0.${{ github.run_number }}
|
|
||||||
|
|
||||||
- name: Run Unit Tests
|
- name: Run Unit Tests
|
||||||
run: |
|
run: |
|
||||||
if [ -d tests/unit ]; then
|
dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj \
|
||||||
dotnet test tests/unit \
|
-c Release \
|
||||||
|
--no-build
|
||||||
|
|
||||||
|
- name: Publish Release Package
|
||||||
|
run: |
|
||||||
|
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
--no-build \
|
--no-build \
|
||||||
|| echo "⚠️ Some tests failed (non-blocking for web service)"
|
-o ./publish
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish Release Package
|
- name: Generate Build Info
|
||||||
run: |
|
run: |
|
||||||
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
|
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||||
-c Release \
|
BUILD_TIME=$(date -d "+9 hours" +'%Y-%m-%d %H:%M:%S KST')
|
||||||
--no-build \
|
mkdir -p ./publish/wwwroot
|
||||||
-o ./publish-output
|
printf '{\n "version": "1.0.%s-%s",\n "built": "%s"\n}\n' "${{ github.run_number }}" "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||||
|
echo "✓ Generated version info: 1.0.${{ github.run_number }}-$COMMIT_HASH @ $BUILD_TIME"
|
||||||
|
|
||||||
- name: Generate Build Info
|
- name: Setup SSH
|
||||||
run: |
|
run: |
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
mkdir -p ~/.ssh
|
||||||
BUILD_TIME=$(date -d "+9 hours" +'%Y-%m-%d %H:%M:%S KST')
|
chmod 700 ~/.ssh
|
||||||
mkdir -p ./publish-output/wwwroot
|
if echo "${{ secrets.SSH_PRIVATE_KEY }}" | grep -q "BEGIN"; then
|
||||||
printf '{\n "version": "1.0.%s-%s",\n "built": "%s"\n}\n' "${{ github.run_number }}" "$COMMIT_HASH" "$BUILD_TIME" > ./publish-output/wwwroot/version.json
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
echo "✓ Generated version info: 1.0.${{ github.run_number }}-$COMMIT_HASH @ $BUILD_TIME"
|
else
|
||||||
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_ed25519 || echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
# SSH_PRIVATE_KEY가 평문 PEM이든 base64든 유연하게 처리
|
|
||||||
if echo "${{ secrets.SSH_PRIVATE_KEY }}" | grep -q "BEGIN"; then
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
else
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_ed25519 || echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
fi
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: Package Artifact
|
|
||||||
run: |
|
|
||||||
tar -czf quant_engine_deploy.tgz -C ./publish-output .
|
|
||||||
echo "✓ Package size: $(du -sh quant_engine_deploy.tgz | cut -f1)"
|
|
||||||
|
|
||||||
- name: Deploy & Verify on Server
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
COMMIT=$(git rev-parse --short HEAD)
|
|
||||||
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
|
||||||
DEPLOY_USER="${{ env.DEPLOY_USER }}"
|
|
||||||
|
|
||||||
# 텔레그램 설정 바인딩 (Secret에 없을 경우 기본값 백업 사용)
|
|
||||||
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
|
||||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
|
|
||||||
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
|
|
||||||
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
|
|
||||||
|
|
||||||
send_telegram() {
|
|
||||||
local text="$1"
|
|
||||||
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
|
||||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
|
||||||
--data-urlencode "text=${text}" \
|
|
||||||
-d "parse_mode=HTML" >/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
notify_failure() {
|
|
||||||
local exit_code=$?
|
|
||||||
send_telegram "❌ <b>QuantEngine 배포 실패</b>
|
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
|
||||||
시간: <code>${TIMESTAMP}</code>
|
|
||||||
단계: deploy-to-prod (SSH Execution)"
|
|
||||||
exit "$exit_code"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap notify_failure ERR
|
|
||||||
|
|
||||||
echo "=== Deploying QuantEngine $COMMIT ($TIMESTAMP) ==="
|
|
||||||
|
|
||||||
# 1. 아티팩트 복사
|
|
||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
|
||||||
quant_engine_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/quantengine_${TIMESTAMP}.tgz"
|
|
||||||
|
|
||||||
# 2. 원격 배포 명령어 통합 (SSH 1회 연결)
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
|
||||||
-o ServerAliveInterval=10 \
|
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
|
||||||
set -e
|
|
||||||
DEPLOY_HOME="/home/kjh2064"
|
|
||||||
DEPLOY_DIR="\$DEPLOY_HOME/deployments/quantengine_${TIMESTAMP}"
|
|
||||||
|
|
||||||
echo "--- [1/4] 압축 해제 ---"
|
|
||||||
mkdir -p "\$DEPLOY_DIR"
|
|
||||||
tar -xzf "/tmp/quantengine_${TIMESTAMP}.tgz" -C "\$DEPLOY_DIR"
|
|
||||||
rm -f "/tmp/quantengine_${TIMESTAMP}.tgz"
|
|
||||||
|
|
||||||
echo "--- [2/4] 심볼릭 링크 전환 ---"
|
|
||||||
ln -sfn "\$DEPLOY_DIR" "${{ env.DEPLOY_PATH }}"
|
|
||||||
|
|
||||||
echo "--- [3/4] 서비스 재시작 ---"
|
|
||||||
sudo /usr/bin/systemctl restart ${{ env.SERVICE_NAME }}
|
|
||||||
|
|
||||||
echo "--- [4/4] 헬스 체크 ---"
|
|
||||||
ATTEMPTS=20
|
|
||||||
for i in \$(seq 1 \$ATTEMPTS); do
|
|
||||||
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/ 2>/dev/null || echo "000")
|
|
||||||
if [ "\$STATUS" = "200" ]; then
|
|
||||||
echo "✓ 헬스체크 성공 (시도 \$i/\$ATTEMPTS, HTTP 200)"
|
|
||||||
# 구 배포 폴더 정리 (최근 5개만 보존)
|
|
||||||
ls -1dt \$DEPLOY_HOME/deployments/quantengine_* 2>/dev/null | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
if [ "\$i" -eq "\$ATTEMPTS" ]; then
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
echo "=== FATAL: 서비스가 헬스체크 응답을 하지 않음 ===" >&2
|
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
systemctl is-active ${{ env.SERVICE_NAME }} >&2 || true
|
|
||||||
journalctl -u ${{ env.SERVICE_NAME }} --no-pager -n 50 >&2
|
- name: Prepare QuantEngine DB Env
|
||||||
|
run: |
|
||||||
|
mkdir -p ./deploy
|
||||||
|
cat > ./deploy/quantengine.env <<EOF
|
||||||
|
ConnectionStrings__DefaultConnection=Host=127.0.0.1;Database=${QUANTENGINE_DB_NAME};Username=${QUANTENGINE_DB_USER};Password=${{ secrets.QUANTENGINE_DB_PASSWORD }};Search Path=quantengine;
|
||||||
|
EOF
|
||||||
|
chmod 600 ./deploy/quantengine.env
|
||||||
|
|
||||||
|
- name: Package Artifact
|
||||||
|
run: |
|
||||||
|
tar -czf quantengine.tar.gz -C ./publish .
|
||||||
|
echo "✓ Package size: $(du -sh quantengine.tar.gz | cut -f1)"
|
||||||
|
|
||||||
|
- name: Deploy & Verify on Server
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
||||||
|
DEPLOY_USER="${{ env.DEPLOY_USER }}"
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
||||||
|
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
|
||||||
|
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
|
||||||
|
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
|
||||||
|
|
||||||
|
send_telegram() {
|
||||||
|
local text="$1"
|
||||||
|
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${text}" \
|
||||||
|
-d "parse_mode=HTML" >/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_failure() {
|
||||||
|
local exit_code=$?
|
||||||
|
send_telegram "❌ <b>QuantEngine 배포 실패</b>
|
||||||
|
|
||||||
|
커밋: <code>${COMMIT}</code>
|
||||||
|
시간: <code>${TIMESTAMP}</code>
|
||||||
|
단계: deploy-to-prod (SSH Execution)"
|
||||||
|
exit "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap notify_failure ERR
|
||||||
|
|
||||||
|
echo "=== Deploying QuantEngine $COMMIT ($TIMESTAMP) ==="
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /home/kjh2064/tmp"
|
||||||
|
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
||||||
|
quantengine.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/quantengine.tar.gz"
|
||||||
|
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
||||||
|
tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh"
|
||||||
|
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
||||||
|
deploy/quantengine.env "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/quantengine.env"
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && CI_DEPLOY=1 /home/kjh2064/tmp/deploy.sh"
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /home/kjh2064/.config && install -m 600 /home/kjh2064/tmp/quantengine.env /home/kjh2064/.config/quantengine.env && rm -f /home/kjh2064/tmp/quantengine.env"
|
||||||
|
|
||||||
|
echo "=== Verifying Loopback Health ==="
|
||||||
|
loopback_headers=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -D - -o /dev/null http://127.0.0.1:5000/")
|
||||||
|
echo "$loopback_headers"
|
||||||
|
if ! printf '%s' "$loopback_headers" | grep -qE '^HTTP/1\.[01] 30[12] '; then
|
||||||
|
echo "Loopback health check failed for quantengine" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$loopback_headers" | grep -qiE '^Location: /login'; then
|
||||||
|
echo "Loopback redirect target is unexpected" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
REMOTE
|
|
||||||
|
|
||||||
echo "✓ 배포 완료: quantengine_${TIMESTAMP} @ $DEPLOY_HOST"
|
echo "=== Verifying Favicon Assets ==="
|
||||||
send_telegram "✅ <b>QuantEngine 배포 완료</b>
|
favicon_svg_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${DEPLOY_HOST}/favicon.svg")
|
||||||
|
favicon_png_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${DEPLOY_HOST}/favicon.png")
|
||||||
커밋: <code>${COMMIT}</code>
|
echo "/favicon.svg -> ${favicon_svg_code}"
|
||||||
시간: <code>${TIMESTAMP}</code>
|
echo "/favicon.png -> ${favicon_png_code}"
|
||||||
대상: <code>${DEPLOY_HOST}</code>"
|
if [ "$favicon_svg_code" != "200" ] && [ "$favicon_png_code" != "200" ]; then
|
||||||
|
echo "Favicon assets are not reachable after deploy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Verifying Public Routes ==="
|
||||||
|
public_root_headers=$(curl -s -D - -o /dev/null "https://quant.taxbaik.com/")
|
||||||
|
login_headers=$(curl -s -D - -o /dev/null "https://quant.taxbaik.com/login")
|
||||||
|
|
||||||
|
public_root_code=$(printf '%s' "$public_root_headers" | awk 'NR==1 {print $2}')
|
||||||
|
login_code=$(printf '%s' "$login_headers" | awk 'NR==1 {print $2}')
|
||||||
|
|
||||||
|
echo "https://quant.taxbaik.com/ -> ${public_root_code}"
|
||||||
|
echo "https://quant.taxbaik.com/login -> ${login_code}"
|
||||||
|
|
||||||
|
if [ "$public_root_code" != "302" ] && [ "$public_root_code" != "200" ]; then
|
||||||
|
echo "Deployment content check failed for public root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$login_code" != "200" ]; then
|
||||||
|
echo "Deployment content check failed for login page" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ 배포 완료: quantengine_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||||
|
send_telegram "✅ <b>QuantEngine 배포 완료</b>
|
||||||
|
|
||||||
|
커밋: <code>${COMMIT}</code>
|
||||||
|
시간: <code>${TIMESTAMP}</code>
|
||||||
|
대상: <code>${DEPLOY_HOST}</code>"
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
name: Snapshot Admin Deployment
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: snapshot-admin-deploy-main
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: 178.104.200.7
|
|
||||||
DEPLOY_USER: kjh2064
|
|
||||||
TELEGRAM_BOT_TOKEN_DEFAULT: "8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-h1ZdV0"
|
|
||||||
TELEGRAM_CHAT_ID_DEFAULT: "-5460205872"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup .NET SDK
|
|
||||||
uses: actions/setup-dotnet@v3
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Publish Blazor Web App
|
|
||||||
run: |
|
|
||||||
echo "[deploy] publishing .NET 10 Blazor app"
|
|
||||||
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj -c Release -o ./publish
|
|
||||||
|
|
||||||
- name: Generate Build Info
|
|
||||||
run: |
|
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
|
||||||
BUILD_TIME=$(date -d "+9 hours" +'%Y-%m-%d %H:%M:%S KST')
|
|
||||||
mkdir -p ./publish/wwwroot
|
|
||||||
printf '{\n "version": "1.0.%s-%s",\n "built": "%s"\n}\n' "${{ github.run_number }}" "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
|
||||||
echo "✓ Generated version info: 1.0.${{ github.run_number }}-$COMMIT_HASH @ $BUILD_TIME"
|
|
||||||
|
|
||||||
|
|
||||||
- name: Compress Artifact
|
|
||||||
run: |
|
|
||||||
echo "[deploy] compressing publish output"
|
|
||||||
tar -czf quantengine.tar.gz -C ./publish .
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
if echo "${{ secrets.SSH_PRIVATE_KEY }}" | grep -q "BEGIN"; then
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
else
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_ed25519 || echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
fi
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: Deploy & Verify on Server
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
COMMIT=$(git rev-parse --short HEAD)
|
|
||||||
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
|
||||||
DEPLOY_USER="${{ env.DEPLOY_USER }}"
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
|
||||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
|
|
||||||
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
|
|
||||||
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
|
|
||||||
|
|
||||||
send_telegram() {
|
|
||||||
local text="$1"
|
|
||||||
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
|
||||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
|
||||||
--data-urlencode "text=${text}" \
|
|
||||||
-d "parse_mode=HTML" >/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
notify_failure() {
|
|
||||||
local exit_code=$?
|
|
||||||
send_telegram "❌ <b>Snapshot Admin 배포 실패</b>
|
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
|
||||||
시간: <code>${TIMESTAMP}</code>
|
|
||||||
단계: snapshot_admin_deploy (Deploy Execution)"
|
|
||||||
exit "$exit_code"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap notify_failure ERR
|
|
||||||
|
|
||||||
echo "=== Deploying Snapshot Admin $COMMIT ($TIMESTAMP) ==="
|
|
||||||
|
|
||||||
# 1. 원격지 임시 폴더 생성 및 업로드
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /home/kjh2064/tmp"
|
|
||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 quantengine.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/quantengine.tar.gz"
|
|
||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh"
|
|
||||||
|
|
||||||
# 2. 배포 스크립트 실행
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && /home/kjh2064/tmp/deploy.sh"
|
|
||||||
|
|
||||||
# 3. 배포 성공 검증
|
|
||||||
echo "=== Verifying Public Routes ==="
|
|
||||||
root_html=$(curl -sf "http://${DEPLOY_HOST}/quant/" 2>/dev/null || echo "")
|
|
||||||
ops_html=$(curl -sf "http://${DEPLOY_HOST}/quant/operations" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
root_code=$(printf '%s' "$root_html" | grep -q "Quant Engine" && echo 200 || echo 500)
|
|
||||||
ops_code=$(printf '%s' "$ops_html" | grep -q "Operational Report" && echo 200 || echo 500)
|
|
||||||
|
|
||||||
echo "/quant/ -> ${root_code}"
|
|
||||||
echo "/quant/operations -> ${ops_code}"
|
|
||||||
|
|
||||||
if [ "$root_code" != "200" ]; then
|
|
||||||
echo "Deployment content check failed for /quant/" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$ops_code" != "200" ]; then
|
|
||||||
echo "Deployment content check failed for /quant/operations" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✓ 배포 완료: quantengine_${TIMESTAMP} @ $DEPLOY_HOST"
|
|
||||||
send_telegram "✅ <b>Snapshot Admin 배포 완료</b>
|
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
|
||||||
시간: <code>${TIMESTAMP}</code>
|
|
||||||
대상: <code>${DEPLOY_HOST}</code>"
|
|
||||||
@@ -110,6 +110,8 @@
|
|||||||
- D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다.
|
- D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다.
|
||||||
- 매주 주말 리밸런싱(rebalance_required=true) 및 매월 1일/11일/21일 중간점검(mid_check_required=true) 운영 cadence를 준수한다.
|
- 매주 주말 리밸런싱(rebalance_required=true) 및 매월 1일/11일/21일 중간점검(mid_check_required=true) 운영 cadence를 준수한다.
|
||||||
- 커밋, 푸쉬, PR 작업 시 반드시 로컬의 .gs 파일을 Google Apps Script 원격 프로젝트에 업로드(python tools/deploy_gas.py 실행)하고, 사용자에게 스프레드시트 상의 스크립트 실행(예: runDataFeed)을 통한 검증을 유도 및 가이드해야 한다.
|
- 커밋, 푸쉬, PR 작업 시 반드시 로컬의 .gs 파일을 Google Apps Script 원격 프로젝트에 업로드(python tools/deploy_gas.py 실행)하고, 사용자에게 스프레드시트 상의 스크립트 실행(예: runDataFeed)을 통한 검증을 유도 및 가이드해야 한다.
|
||||||
|
- QuantEngine 배포는 CI 전용이다. 로컬에서 서버로 산출물을 직접 업로드하거나 `scp`/`rsync`로 수동 반영하지 않는다. 실배포는 `.gitea/workflows/deploy-prod.yml`만 사용하며, 로컬 스크립트는 CI 환경에서만 실행 가능해야 한다.
|
||||||
|
- 원격 서버 확인이 필요하면 `ssh kjh2064@178.104.200.7` 접속을 먼저 시도하고, 사용자에게 매번 접속 확인을 요구하지 말고 직접 상태/로그/헬스체크를 수집한 뒤 결과만 보고한다.
|
||||||
|
|
||||||
## 4. 보고 규칙
|
## 4. 보고 규칙
|
||||||
- 모든 숫자에는 반드시 provenance(출처)를 남기며, 출처가 유효하지 않거나 없는 숫자는 보고서 표기를 전면 배제(DATA_MISSING 처리)한다.
|
- 모든 숫자에는 반드시 provenance(출처)를 남기며, 출처가 유효하지 않거나 없는 숫자는 보고서 표기를 전면 배제(DATA_MISSING 처리)한다.
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ npm run prepare-upload-zip
|
|||||||
## CI / 배포 분리
|
## CI / 배포 분리
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml`은 검증 전용이다.
|
- `.gitea/workflows/ci.yml`은 검증 전용이다.
|
||||||
- `.gitea/workflows/snapshot_admin_deploy.yml`은 실배포 전용이다.
|
- `.gitea/workflows/deploy-prod.yml`은 실배포 전용이다.
|
||||||
- 공개 URL `http://178.104.200.7/quant/` 갱신은 deploy workflow 성공 여부로 판단한다.
|
- 공개 URL `http://178.104.200.7/quant/` 갱신은 deploy workflow 성공 여부로 판단한다.
|
||||||
|
|
||||||
## 운영 리포트 계약
|
## 운영 리포트 계약
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# HTTP 80 ➜ HTTPS 443 Redirect
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name taxbaik.com www.taxbaik.com gitea.taxbaik.com quant.taxbaik.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# TaxBaik 홈페이지 (통합 앱)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name taxbaik.com www.taxbaik.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5001/taxbaik/;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gitea (코드 저장소)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name gitea.taxbaik.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
proxy_connect_timeout 300;
|
||||||
|
proxy_send_timeout 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# QuantEngine (Blazor Admin)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name quant.taxbaik.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000/;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-68
@@ -16,8 +16,8 @@
|
|||||||
| 3.2 | [Python 가상 환경](#32-python-가상-환경) | `~/.venv`, `python3` 사용 규칙 |
|
| 3.2 | [Python 가상 환경](#32-python-가상-환경) | `~/.venv`, `python3` 사용 규칙 |
|
||||||
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
||||||
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
||||||
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
|
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 443, 2222, 3000, 5000, 5001, 5432 |
|
||||||
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
|
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 가상 호스트 기반 분기 |
|
||||||
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
|
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
|
||||||
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
||||||
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
||||||
@@ -117,55 +117,30 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
|||||||
| 포트 | 서비스 | 바인드 | 비고 |
|
| 포트 | 서비스 | 바인드 | 비고 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **22** | SSH | `0.0.0.0` | 공개키 전용 |
|
| **22** | SSH | `0.0.0.0` | 공개키 전용 |
|
||||||
| **80** | Nginx (리버스 프록시) | `0.0.0.0` | 외부 진입점 |
|
| **80** | Nginx (HTTP) | `0.0.0.0` | 443 HTTPS로 리다이렉트 |
|
||||||
|
| **443** | Nginx (HTTPS) | `0.0.0.0` | SSL 가상 호스트 진입점 |
|
||||||
| **2222** | Gitea SSH | `0.0.0.0` | Git SSH 접속 |
|
| **2222** | Gitea SSH | `0.0.0.0` | Git SSH 접속 |
|
||||||
| **3000** | Gitea Web | `127.0.0.1` | Nginx 프록시 경유 |
|
| **3000** | Gitea Web | `127.0.0.1` | Nginx 프록시 경유 (`gitea.taxbaik.com`) |
|
||||||
| **5000** | QuantEngine Blazor | `127.0.0.1` | Nginx `/quant/` 경유 |
|
| **5000** | QuantEngine Blazor | `127.0.0.1` | Nginx 프록시 경유 (`quant.taxbaik.com`) |
|
||||||
|
| **5001** | TaxBaik 홈페이지 | `127.0.0.1` | Nginx 프록시 경유 (`taxbaik.com` / `www.taxbaik.com`) |
|
||||||
| **5432** | PostgreSQL | `127.0.0.1` + `172.17.0.1` | 로컬 + Docker 네트워크 |
|
| **5432** | PostgreSQL | `127.0.0.1` + `172.17.0.1` | 로컬 + Docker 네트워크 |
|
||||||
|
|
||||||
### 4.2. Nginx 리버스 프록시
|
### 4.2. Nginx 리버스 프록시
|
||||||
|
|
||||||
```nginx
|
도메인 기반 가상 호스트(Virtual Host) 방식을 사용하여 각 도메인 요청을 내부 서비스로 연결하고, SSL(HTTPS)을 필수로 적용합니다. HTTP(80) 포트 요청은 자동으로 HTTPS(443)로 리다이렉트됩니다.
|
||||||
# /etc/nginx/sites-enabled/gitea-ip.conf
|
|
||||||
|
|
||||||
server {
|
상세 Nginx 설정 백업은 `deploy/nginx-taxbaik-domains.conf`에 위치합니다.
|
||||||
listen 80 default_server;
|
|
||||||
listen [::]:80 default_server;
|
|
||||||
server_name _;
|
|
||||||
client_max_body_size 512M;
|
|
||||||
|
|
||||||
# QuantEngine Blazor Web App
|
#### 가상 호스트 설정 개요
|
||||||
location /quant/ {
|
- **TaxBaik 홈페이지** (`https://taxbaik.com`, `https://www.taxbaik.com`) ➜ `http://127.0.0.1:5001/taxbaik/`
|
||||||
proxy_pass http://127.0.0.1:5000/;
|
- **Gitea (코드 저장소)** (`https://gitea.taxbaik.com`) ➜ `http://127.0.0.1:3000`
|
||||||
proxy_http_version 1.1;
|
- **QuantEngine (Blazor Admin)** (`https://quant.taxbaik.com`) ➜ `http://127.0.0.1:5000/`
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Gitea (기본)
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
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;
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
proxy_connect_timeout 300;
|
|
||||||
proxy_send_timeout 300;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**라우팅 요약**:
|
**라우팅 요약**:
|
||||||
- `http://178.104.200.7/` → Gitea Web UI
|
- `https://taxbaik.com` & `https://www.taxbaik.com` ➜ TaxBaik 홈페이지 (통합 앱)
|
||||||
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
|
- `https://gitea.taxbaik.com` ➜ Gitea Web UI
|
||||||
- `ssh://178.104.200.7:2222` → Gitea Git SSH
|
- `https://quant.taxbaik.com` ➜ QuantEngine Blazor Admin
|
||||||
|
- `ssh://git@gitea.taxbaik.com:2222` ➜ Gitea Git SSH
|
||||||
|
|
||||||
## 5. Gitea
|
## 5. Gitea
|
||||||
|
|
||||||
@@ -231,8 +206,9 @@ services:
|
|||||||
### 6.4. CI / 배포 분리
|
### 6.4. CI / 배포 분리
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다.
|
- `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다.
|
||||||
- `.gitea/workflows/snapshot_admin_deploy.yml`: 실배포 전용. `dotnet publish` 후 `tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
|
- `.gitea/workflows/deploy-prod.yml`: 실배포 전용. `dotnet publish` 후 `tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
|
||||||
- 공개 URL `/quant/` 갱신은 `snapshot_admin_deploy.yml`의 성공 여부를 기준으로 판단한다.
|
- 수동 배포 금지: 로컬에서 `scp`/`rsync`로 `quantengine_active`를 갱신하지 않는다. 배포는 CI가 원격에서만 수행하고, 로컬 스크립트는 `CI_DEPLOY=1` 없이 실행되면 실패해야 한다.
|
||||||
|
- 공개 URL 갱신은 `deploy-prod.yml`의 성공 여부를 기준으로 판단한다.
|
||||||
|
|
||||||
### 6.2. 러너 설정
|
### 6.2. 러너 설정
|
||||||
|
|
||||||
@@ -335,8 +311,8 @@ ClientAliveCountMax 2
|
|||||||
|
|
||||||
- **상태**: `ENABLED=yes` (`/etc/ufw/ufw.conf`)
|
- **상태**: `ENABLED=yes` (`/etc/ufw/ufw.conf`)
|
||||||
- **로그 레벨**: `low`
|
- **로그 레벨**: `low`
|
||||||
- **외부 개방 포트**: 22 (SSH), 80 (HTTP/Nginx), 2222 (Gitea SSH)
|
- **외부 개방 포트**: 22 (SSH), 80 (HTTP), 443 (HTTPS), 2222 (Gitea SSH)
|
||||||
- **내부 전용**: 3000 (Gitea Web), 5000 (QuantEngine), 5432 (PostgreSQL)
|
- **내부 전용**: 3000 (Gitea Web), 5000 (QuantEngine), 5001 (TaxBaik Web), 5432 (PostgreSQL)
|
||||||
|
|
||||||
> 상세 규칙 확인: `sudo ufw status numbered` (TTY + sudo 비밀번호 필요)
|
> 상세 규칙 확인: `sudo ufw status numbered` (TTY + sudo 비밀번호 필요)
|
||||||
|
|
||||||
@@ -349,8 +325,9 @@ ClientAliveCountMax 2
|
|||||||
|
|
||||||
- Gitea Web: `127.0.0.1:3000` (로컬 전용)
|
- Gitea Web: `127.0.0.1:3000` (로컬 전용)
|
||||||
- QuantEngine: `127.0.0.1:5000` (로컬 전용)
|
- QuantEngine: `127.0.0.1:5000` (로컬 전용)
|
||||||
|
- TaxBaik Web: `127.0.0.1:5001` (로컬 전용)
|
||||||
- PostgreSQL: `127.0.0.1` + Docker bridge (`172.17.0.1`)
|
- PostgreSQL: `127.0.0.1` + Docker bridge (`172.17.0.1`)
|
||||||
- 외부 노출: SSH(22), HTTP(80), Gitea SSH(2222)만 개방
|
- 외부 노출: SSH(22), HTTP(80), HTTPS(443), Gitea SSH(2222)만 개방
|
||||||
|
|
||||||
## 10. 디렉토리 맵
|
## 10. 디렉토리 맵
|
||||||
|
|
||||||
@@ -390,7 +367,7 @@ ClientAliveCountMax 2
|
|||||||
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
||||||
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
||||||
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
||||||
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
|
| **리버스 프록시** | Synology 내장 | Nginx 도메인 가상 호스트 및 SSL (HTTPS) 적용 (`deploy/nginx-taxbaik-domains.conf`) |
|
||||||
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
||||||
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
||||||
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
||||||
@@ -425,19 +402,9 @@ docker ps -a
|
|||||||
### QuantEngine 배포
|
### QuantEngine 배포
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 새 배포 디렉토리 생성
|
# CI에서만 배포
|
||||||
DEPLOY_DIR=~/deployments/quantengine_$(date +%Y%m%d_%H%M%S)
|
# 로컬에서 scp/rsync로 quantengine_active를 갱신하지 않는다.
|
||||||
mkdir -p "$DEPLOY_DIR"
|
# 배포는 .gitea/workflows/deploy-prod.yml 실행 결과로만 반영한다.
|
||||||
|
|
||||||
# 2. 빌드 산출물 복사 (로컬에서 scp 또는 CI에서)
|
|
||||||
scp -r publish/* kjh2064@178.104.200.7:"$DEPLOY_DIR"/
|
|
||||||
|
|
||||||
# 3. symlink 교체
|
|
||||||
ln -sfn "$DEPLOY_DIR" ~/quantengine_active
|
|
||||||
|
|
||||||
# 4. 서비스 재시작
|
|
||||||
sudo systemctl restart quantengine
|
|
||||||
sudo systemctl status quantengine
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gitea Act Runner 등록
|
### Gitea Act Runner 등록
|
||||||
@@ -452,14 +419,20 @@ docker run -d \
|
|||||||
gitea/act_runner:latest
|
gitea/act_runner:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSH 접속
|
### SSH 접속 및 Git 원격 설정
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows 로컬에서
|
# Windows 로컬에서 서버 SSH 접속
|
||||||
ssh kjh2064@178.104.200.7
|
ssh kjh2064@178.104.200.7
|
||||||
|
|
||||||
# Gitea Git 접속
|
# 로컬 프로젝트의 Git Remote URL 변경 (Gitea 도메인 기반 HTTPS 적용)
|
||||||
git remote set-url origin ssh://git@178.104.200.7:2222/kjh2064/QuantEngineByItz.git
|
# 1) 현재 설정된 remote url 확인
|
||||||
|
git remote -v
|
||||||
|
# 2) 새로운 도메인 주소로 원격 URL 변경
|
||||||
|
git remote set-url origin https://gitea.taxbaik.com/kjh2064/QuantEngineByItz.git
|
||||||
|
|
||||||
|
# Gitea Git SSH 접속 (기존 2222 포트 유지)
|
||||||
|
git remote set-url origin ssh://git@gitea.taxbaik.com:2222/kjh2064/QuantEngineByItz.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## 13. 검증 하네스
|
## 13. 검증 하네스
|
||||||
@@ -514,6 +487,27 @@ ssh -T -p 2222 git@178.104.200.7 2>&1 | head -1
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **수집 일시**: 2026-06-26 09:55 KST
|
## 14. 트러블슈팅 (Troubleshooting)
|
||||||
> **수집 방법**: `ssh kjh2064@178.104.200.7` 라이브 명령 실행
|
|
||||||
> **provenance**: 모든 값은 서버 실시간 명령 출력에서 추출. 임의 값 없음.
|
### 14.1. Certbot / APT 패키지 설치 시 Microsoft 리포지토리 404 오류
|
||||||
|
- **증상**: `sudo apt-get update` 실행 시 Microsoft 패키지 저장소에서 `404 Not Found` 에러가 발생하며 패키지 목록 갱신이 중단되고, 이로 인해 `certbot` 설치가 `sudo: certbot: command not found` 에러로 실패하는 현상.
|
||||||
|
- **원인**: Ubuntu 26.04 (Resolute) 환경에서 Microsoft의 잘못된 리포지토리(26.04 경로에 focal/20.04 릴리스가 설정된 상태)를 참조하여 발생.
|
||||||
|
- **해결 방안**:
|
||||||
|
1. 문제가 되는 Microsoft apt 소스 설정 파일을 삭제하거나 비활성화합니다.
|
||||||
|
```bash
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
```
|
||||||
|
2. APT 패키지 목록을 다시 업데이트하고 Certbot 및 Nginx 플러그인을 설치합니다.
|
||||||
|
```bash
|
||||||
|
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
|
||||||
|
```
|
||||||
|
3. 인증서 발급 및 설정을 적용합니다.
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d taxbaik.com -d www.taxbaik.com -d gitea.taxbaik.com -d quant.taxbaik.com --register-unsafely-without-email --agree-tos --non-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **수집 일시**: 2026-06-26 09:55 KST (추가 업데이트: 2026-07-01)
|
||||||
|
> **수집 방법**: `ssh kjh2064@178.104.200.7` 라이브 명령 및 트러블슈팅 사례 수집
|
||||||
|
> **provenance**: 모든 값은 서버 실시간 명령 출력 및 실제 오류 대처 조치 로그에서 추출. 임의 값 없음.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ This document outlines the security configuration, role definitions, and access
|
|||||||
The Quant Investment Engine operates strictly within the `quantengine` schema to prevent namespace pollution and protect system catalog tables.
|
The Quant Investment Engine operates strictly within the `quantengine` schema to prevent namespace pollution and protect system catalog tables.
|
||||||
|
|
||||||
* **Schema**: `quantengine`
|
* **Schema**: `quantengine`
|
||||||
* **Default Database**: `giteadb`
|
* **Default Database**: `quantenginedb`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ To ensure the principle of least privilege, we define three main database roles:
|
|||||||
* **Permissions**:
|
* **Permissions**:
|
||||||
```sql
|
```sql
|
||||||
CREATE ROLE quantengine_owner WITH LOGIN PASSWORD 'OwnerPasswordSecure';
|
CREATE ROLE quantengine_owner WITH LOGIN PASSWORD 'OwnerPasswordSecure';
|
||||||
GRANT ALL PRIVILEGES ON DATABASE giteadb TO quantengine_owner;
|
GRANT ALL PRIVILEGES ON DATABASE quantenginedb TO quantengine_owner;
|
||||||
GRANT ALL PRIVILEGES ON SCHEMA quantengine TO quantengine_owner;
|
GRANT ALL PRIVILEGES ON SCHEMA quantengine TO quantengine_owner;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA quantengine GRANT ALL ON TABLES TO quantengine_owner;
|
ALTER DEFAULT PRIVILEGES IN SCHEMA quantengine GRANT ALL ON TABLES TO quantengine_owner;
|
||||||
```
|
```
|
||||||
@@ -32,7 +32,7 @@ To ensure the principle of least privilege, we define three main database roles:
|
|||||||
* **Permissions**:
|
* **Permissions**:
|
||||||
```sql
|
```sql
|
||||||
CREATE ROLE quantengine_app WITH LOGIN PASSWORD 'AppPasswordSecure';
|
CREATE ROLE quantengine_app WITH LOGIN PASSWORD 'AppPasswordSecure';
|
||||||
GRANT CONNECT ON DATABASE giteadb TO quantengine_app;
|
GRANT CONNECT ON DATABASE quantenginedb TO quantengine_app;
|
||||||
GRANT USAGE ON SCHEMA quantengine TO quantengine_app;
|
GRANT USAGE ON SCHEMA quantengine TO quantengine_app;
|
||||||
|
|
||||||
-- Grant CRUD permissions on tables & sequences
|
-- Grant CRUD permissions on tables & sequences
|
||||||
@@ -48,7 +48,7 @@ To ensure the principle of least privilege, we define three main database roles:
|
|||||||
* **Permissions**:
|
* **Permissions**:
|
||||||
```sql
|
```sql
|
||||||
CREATE ROLE quantengine_readonly WITH LOGIN PASSWORD 'ReadonlyPasswordSecure';
|
CREATE ROLE quantengine_readonly WITH LOGIN PASSWORD 'ReadonlyPasswordSecure';
|
||||||
GRANT CONNECT ON DATABASE giteadb TO quantengine_readonly;
|
GRANT CONNECT ON DATABASE quantenginedb TO quantengine_readonly;
|
||||||
GRANT USAGE ON SCHEMA quantengine TO quantengine_readonly;
|
GRANT USAGE ON SCHEMA quantengine TO quantengine_readonly;
|
||||||
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA quantengine TO quantengine_readonly;
|
GRANT SELECT ON ALL TABLES IN SCHEMA quantengine TO quantengine_readonly;
|
||||||
@@ -63,7 +63,7 @@ To ensure the principle of least privilege, we define three main database roles:
|
|||||||
* Never store connection strings with plaintext passwords in version control.
|
* Never store connection strings with plaintext passwords in version control.
|
||||||
* `appsettings.json` must only contain placeholder configurations.
|
* `appsettings.json` must only contain placeholder configurations.
|
||||||
* Inject the connection string at runtime using environment variables:
|
* Inject the connection string at runtime using environment variables:
|
||||||
`ConnectionStrings__DefaultConnection="Host=127.0.0.1;Database=giteadb;Username=quantengine_app;Password=YourSecurePassword;Search Path=quantengine;"`
|
`ConnectionStrings__DefaultConnection="Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=YourSecurePassword;Search Path=quantengine;"`
|
||||||
|
|
||||||
2. **Network Security**:
|
2. **Network Security**:
|
||||||
* Bind PostgreSQL only to local interfaces (`127.0.0.1`) or secure private network interfaces.
|
* Bind PostgreSQL only to local interfaces (`127.0.0.1`) or secure private network interfaces.
|
||||||
|
|||||||
+2
-2
@@ -925,7 +925,7 @@ python tools/validate_specs.py → PASS
|
|||||||
|------|------|
|
|------|------|
|
||||||
| **작업** | `src/quant_engine/snapshot_admin_server_v1.py`(Python 어드민 웹 UI)를 Gitea CI/CD 배포 스텝을 통해 Synology NAS에서 상시 서비스로 운영할 수 있는지 검토 |
|
| **작업** | `src/quant_engine/snapshot_admin_server_v1.py`(Python 어드민 웹 UI)를 Gitea CI/CD 배포 스텝을 통해 Synology NAS에서 상시 서비스로 운영할 수 있는지 검토 |
|
||||||
| **현재 상태** | **기술적으로는 가능**. 기본 루프백 보호 + Basic Auth 게이트를 추가했고, Synology 외부 노출은 리버스 프록시 기반 POC로 가이드함. 실배포 검증은 아직 필요 |
|
| **현재 상태** | **기술적으로는 가능**. 기본 루프백 보호 + Basic Auth 게이트를 추가했고, Synology 외부 노출은 리버스 프록시 기반 POC로 가이드함. 실배포 검증은 아직 필요 |
|
||||||
| **운영 분리** | `snapshot_admin.yml`은 `push`용 smoke 검증과 `workflow_dispatch`용 full 검증으로 분리하고, 배포는 별도 `snapshot_admin_deploy.yml` `workflow_dispatch`로 떼어냈다. `push`에서는 `Validate Snapshot Admin Workflow`까지만, full 검증에서는 `Validate Snapshot Admin Web UI`까지 수행한다. |
|
| **운영 분리** | `snapshot_admin.yml`은 `push`용 smoke 검증과 `workflow_dispatch`용 full 검증으로 분리하고, 배포는 별도 `deploy-prod.yml` `workflow_dispatch`로 떼어냈다. `push`에서는 `Validate Snapshot Admin Workflow`까지만, full 검증에서는 `Validate Snapshot Admin Web UI`까지 수행한다. |
|
||||||
| **runner 주의** | Gitea runner를 Docker mode로 두면 job 종료 시 `Cleaning up container` 로그가 남는다. host label로 재등록하면 job container 정리 로그를 피할 수 있다. |
|
| **runner 주의** | Gitea runner를 Docker mode로 두면 job 종료 시 `Cleaning up container` 로그가 남는다. host label로 재등록하면 job container 정리 로그를 피할 수 있다. |
|
||||||
| **KIS 분리** | `kis_data_collection.yml`은 `workflow_dispatch`용 mock/config smoke와 `schedule`용 live collection으로 분리했다. 수동 디스패치는 실제 수집을 돌리지 않고, 실수집은 스케줄 전용이다. |
|
| **KIS 분리** | `kis_data_collection.yml`은 `workflow_dispatch`용 mock/config smoke와 `schedule`용 live collection으로 분리했다. 수동 디스패치는 실제 수집을 돌리지 않고, 실수집은 스케줄 전용이다. |
|
||||||
| **담당 파일** | `.gitea/workflows/ci.yml`, `tools/run_snapshot_admin_server_v1.py`, `src/quant_engine/snapshot_admin_server_v1.py`, `docs/SYNOLOGY_SNAPSHOT_ADMIN_POC.md`, `docs/WBS_7_9_EVIDENCE_PACKET_FINAL.md` |
|
| **담당 파일** | `.gitea/workflows/ci.yml`, `tools/run_snapshot_admin_server_v1.py`, `src/quant_engine/snapshot_admin_server_v1.py`, `docs/SYNOLOGY_SNAPSHOT_ADMIN_POC.md`, `docs/WBS_7_9_EVIDENCE_PACKET_FINAL.md` |
|
||||||
@@ -1651,7 +1651,7 @@ WBS-10.1 (기반 결함 수정)
|
|||||||
| 10.10.2 | Dashboard 상태 페이지 — 데이터 비의존형 요약으로 단순화 | DB 실패 시에도 200 응답 (완료) |
|
| 10.10.2 | Dashboard 상태 페이지 — 데이터 비의존형 요약으로 단순화 | DB 실패 시에도 200 응답 (완료) |
|
||||||
| 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Operations만 표시 (완료) |
|
| 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Operations만 표시 (완료) |
|
||||||
| 10.10.4 | 다크 모드 + 반응형 레이아웃 적용 | 브라우저 렌더링 정상 확인 (완료) |
|
| 10.10.4 | 다크 모드 + 반응형 레이아웃 적용 | 브라우저 렌더링 정상 확인 (완료) |
|
||||||
| 10.10.5 | 배포 동기화 | `snapshot_admin_deploy.yml`가 `/quant/`와 `/quant/operations` 공개 라우트를 배포 후 검증하도록 구성됨 (완료) |
|
| 10.10.5 | 배포 동기화 | `deploy-prod.yml`가 공개 라우트를 배포 후 검증하도록 구성됨 (완료) |
|
||||||
|
|
||||||
**성공 하네스 (데이터 기준)**:
|
**성공 하네스 (데이터 기준)**:
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ public class ApplicationServiceTests
|
|||||||
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
|
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
|
||||||
|
|
||||||
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
|
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
|
||||||
|
public Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync() => Task.FromResult(Enumerable.Empty<WorkspaceAccount>());
|
||||||
|
public Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username) => Task.FromResult<WorkspaceAccount?>(null);
|
||||||
|
public Task<bool> UpsertAccountAsync(WorkspaceAccount account) => Task.FromResult(true);
|
||||||
|
public Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash) => Task.FromResult<WorkspaceSession?>(null);
|
||||||
|
public Task<bool> UpsertSessionAsync(WorkspaceSession session) => Task.FromResult(true);
|
||||||
|
public Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt) => Task.FromResult(true);
|
||||||
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
|
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
|
||||||
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
|
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
|
||||||
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
|
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ namespace QuantEngine.Core.Interfaces
|
|||||||
{
|
{
|
||||||
public interface IWorkspaceRepository
|
public interface IWorkspaceRepository
|
||||||
{
|
{
|
||||||
|
// Accounts
|
||||||
|
Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync();
|
||||||
|
Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username);
|
||||||
|
Task<bool> UpsertAccountAsync(WorkspaceAccount account);
|
||||||
|
Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash);
|
||||||
|
Task<bool> UpsertSessionAsync(WorkspaceSession session);
|
||||||
|
Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
Task<IEnumerable<Setting>> GetSettingsAsync();
|
Task<IEnumerable<Setting>> GetSettingsAsync();
|
||||||
Task<Setting?> GetSettingByKeyAsync(string key);
|
Task<Setting?> GetSettingByKeyAsync(string key);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace QuantEngine.Core.Models
|
||||||
|
{
|
||||||
|
public class WorkspaceAccount
|
||||||
|
{
|
||||||
|
public int Ordinal { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "Admin";
|
||||||
|
public string IsActive { get; set; } = "true";
|
||||||
|
public string CreatedAt { get; set; } = string.Empty;
|
||||||
|
public string UpdatedAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace QuantEngine.Core.Models
|
||||||
|
{
|
||||||
|
public class WorkspaceSession
|
||||||
|
{
|
||||||
|
public string SessionTokenHash { get; set; } = string.Empty;
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "Admin";
|
||||||
|
public string CreatedAt { get; set; } = string.Empty;
|
||||||
|
public string ExpiresAt { get; set; } = string.Empty;
|
||||||
|
public string? RevokedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,32 @@ namespace QuantEngine.Infrastructure.Data
|
|||||||
);
|
);
|
||||||
");
|
");
|
||||||
|
|
||||||
|
// 0b. workspace_account
|
||||||
|
conn.Execute(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_account (
|
||||||
|
ordinal INT NOT NULL,
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'Admin',
|
||||||
|
is_active TEXT NOT NULL DEFAULT 'true',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_account_active ON workspace_account(is_active, username);
|
||||||
|
");
|
||||||
|
|
||||||
|
conn.Execute(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_session (
|
||||||
|
session_token_hash TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'Admin',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_session_username ON workspace_session(username, expires_at DESC);
|
||||||
|
");
|
||||||
|
|
||||||
// 1. collection_runs
|
// 1. collection_runs
|
||||||
conn.Execute(@"
|
conn.Execute(@"
|
||||||
CREATE TABLE IF NOT EXISTS collection_runs (
|
CREATE TABLE IF NOT EXISTS collection_runs (
|
||||||
@@ -157,6 +183,16 @@ namespace QuantEngine.Infrastructure.Data
|
|||||||
);
|
);
|
||||||
");
|
");
|
||||||
|
|
||||||
|
conn.Execute(@"
|
||||||
|
INSERT INTO quantengine.workspace_account (
|
||||||
|
ordinal, username, password_hash, role, is_active, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT 1, 'admin', '8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918', 'Admin', 'true', NOW()::text, NOW()::text
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM quantengine.workspace_account WHERE username = 'admin'
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
// 10. engine_history schema and tables
|
// 10. engine_history schema and tables
|
||||||
conn.Execute(@"
|
conn.Execute(@"
|
||||||
CREATE SCHEMA IF NOT EXISTS engine_history;
|
CREATE SCHEMA IF NOT EXISTS engine_history;
|
||||||
|
|||||||
@@ -17,6 +17,89 @@ namespace QuantEngine.Infrastructure.Repositories
|
|||||||
_connectionFactory = connectionFactory;
|
_connectionFactory = connectionFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
public async Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync()
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QueryAsync<WorkspaceAccount>(@"
|
||||||
|
SELECT ordinal, username as Username, password_hash as PasswordHash, role as Role,
|
||||||
|
is_active as IsActive, created_at as CreatedAt, updated_at as UpdatedAt
|
||||||
|
FROM quantengine.workspace_account
|
||||||
|
ORDER BY ordinal ASC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
|
||||||
|
SELECT ordinal, username as Username, password_hash as PasswordHash, role as Role,
|
||||||
|
is_active as IsActive, created_at as CreatedAt, updated_at as UpdatedAt
|
||||||
|
FROM quantengine.workspace_account
|
||||||
|
WHERE username = @Username",
|
||||||
|
new { Username = username }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpsertAccountAsync(WorkspaceAccount account)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var affected = await conn.ExecuteAsync(@"
|
||||||
|
INSERT INTO quantengine.workspace_account (ordinal, username, password_hash, role, is_active, created_at, updated_at)
|
||||||
|
VALUES (@Ordinal, @Username, @PasswordHash, @Role, @IsActive, @CreatedAt, @UpdatedAt)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
ordinal = EXCLUDED.ordinal,
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = EXCLUDED.updated_at",
|
||||||
|
account
|
||||||
|
);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<WorkspaceSession>(@"
|
||||||
|
SELECT session_token_hash as SessionTokenHash, username as Username, role as Role,
|
||||||
|
created_at as CreatedAt, expires_at as ExpiresAt, revoked_at as RevokedAt
|
||||||
|
FROM quantengine.workspace_session
|
||||||
|
WHERE session_token_hash = @TokenHash",
|
||||||
|
new { TokenHash = tokenHash }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpsertSessionAsync(WorkspaceSession session)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var affected = await conn.ExecuteAsync(@"
|
||||||
|
INSERT INTO quantengine.workspace_session
|
||||||
|
(session_token_hash, username, role, created_at, expires_at, revoked_at)
|
||||||
|
VALUES
|
||||||
|
(@SessionTokenHash, @Username, @Role, @CreatedAt, @ExpiresAt, @RevokedAt)
|
||||||
|
ON CONFLICT (session_token_hash) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
expires_at = EXCLUDED.expires_at,
|
||||||
|
revoked_at = EXCLUDED.revoked_at",
|
||||||
|
session
|
||||||
|
);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var affected = await conn.ExecuteAsync(@"
|
||||||
|
UPDATE quantengine.workspace_session
|
||||||
|
SET revoked_at = @RevokedAt
|
||||||
|
WHERE session_token_hash = @TokenHash",
|
||||||
|
new { TokenHash = tokenHash, RevokedAt = revokedAt }
|
||||||
|
);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
public async Task<IEnumerable<Setting>> GetSettingsAsync()
|
public async Task<IEnumerable<Setting>> GetSettingsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using QuantEngine.Web.Client.Services;
|
||||||
|
|
||||||
|
namespace QuantEngine.Web.Client.Infrastructure
|
||||||
|
{
|
||||||
|
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private readonly LocalStorageService _localStorage;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
|
||||||
|
private const string TokenKey = "quant_admin_access_token";
|
||||||
|
private const string UsernameKey = "quant_admin_username";
|
||||||
|
private const string RoleKey = "quant_admin_role";
|
||||||
|
private const string RememberUsernameKey = "quant_admin_remember_username";
|
||||||
|
|
||||||
|
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http)
|
||||||
|
{
|
||||||
|
_localStorage = localStorage;
|
||||||
|
_http = http;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = await _localStorage.GetAsync<string>(TokenKey);
|
||||||
|
var username = await _localStorage.GetAsync<string>(UsernameKey);
|
||||||
|
var role = await _localStorage.GetAsync<string>(RoleKey) ?? "Admin";
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "api/auth/me");
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
var response = await _http.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await MarkUserAsLoggedOutAsync();
|
||||||
|
return new AuthenticationState(_anonymous);
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, username),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
}, "QuantAdminAuth");
|
||||||
|
|
||||||
|
var user = new ClaimsPrincipal(identity);
|
||||||
|
return new AuthenticationState(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Return anonymous if localStorage isn't ready
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthenticationState(_anonymous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role)
|
||||||
|
{
|
||||||
|
await MarkUserAsAuthenticatedAsync(username, accessToken, role, rememberUsername: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role, bool rememberUsername)
|
||||||
|
{
|
||||||
|
await _localStorage.SetAsync(TokenKey, accessToken);
|
||||||
|
if (rememberUsername)
|
||||||
|
{
|
||||||
|
await _localStorage.SetAsync(UsernameKey, username);
|
||||||
|
await _localStorage.SetAsync(RememberUsernameKey, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _localStorage.DeleteAsync(UsernameKey);
|
||||||
|
await _localStorage.SetAsync(RememberUsernameKey, false);
|
||||||
|
}
|
||||||
|
await _localStorage.SetAsync(RoleKey, role);
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, username),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
}, "QuantAdminAuth");
|
||||||
|
|
||||||
|
var user = new ClaimsPrincipal(identity);
|
||||||
|
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkUserAsLoggedOutAsync()
|
||||||
|
{
|
||||||
|
await _localStorage.DeleteAsync(TokenKey);
|
||||||
|
await _localStorage.DeleteAsync(RoleKey);
|
||||||
|
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
|
||||||
|
if (!rememberUsername)
|
||||||
|
{
|
||||||
|
await _localStorage.DeleteAsync(UsernameKey);
|
||||||
|
}
|
||||||
|
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LogoutFromServerAsync()
|
||||||
|
{
|
||||||
|
var token = await _localStorage.GetAsync<string>(TokenKey);
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "api/auth/logout");
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
await _http.SendAsync(request);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort server revocation; always clear local state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MarkUserAsLoggedOutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetRememberedUsernameAsync()
|
||||||
|
{
|
||||||
|
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
|
||||||
|
if (!rememberUsername)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _localStorage.GetAsync<string>(UsernameKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@Body
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<MudLayout>
|
||||||
|
<MudAppBar Elevation="1" Dense="true">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(() => navOpen = !navOpen)" />
|
||||||
|
<MudText Typo="Typo.h6">QuantEngine v@appVersion</MudText>
|
||||||
|
<MudSpacer />
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<MudText Typo="Typo.body2">관리자 (@context.User.Identity?.Name)</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="HandleLogoutAsync">로그아웃</MudButton>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</MudAppBar>
|
||||||
|
|
||||||
|
<MudDrawer Open="@navOpen" Variant="DrawerVariant.Responsive" Elevation="1">
|
||||||
|
<MudNavMenu>
|
||||||
|
<NavMenu />
|
||||||
|
</MudNavMenu>
|
||||||
|
<div style="padding: 16px; border-top: 1px solid var(--mud-palette-lines-default);">
|
||||||
|
<MudText Typo="Typo.caption">QuantEngine v@appVersion</MudText>
|
||||||
|
<MudText Typo="Typo.caption">배포: @buildTime</MudText>
|
||||||
|
</div>
|
||||||
|
</MudDrawer>
|
||||||
|
|
||||||
|
<MudMainContent>
|
||||||
|
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||||
|
@Body
|
||||||
|
</MudContainer>
|
||||||
|
</MudMainContent>
|
||||||
|
</MudLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool navOpen = true;
|
||||||
|
private string appVersion = "Local Debug";
|
||||||
|
private string buildTime = "N/A";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var versionInfo = await Http.GetFromJsonAsync<VersionInfo>("version.json");
|
||||||
|
if (versionInfo != null)
|
||||||
|
{
|
||||||
|
appVersion = versionInfo.Version ?? "Local Debug";
|
||||||
|
buildTime = versionInfo.Built ?? "N/A";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleLogoutAsync()
|
||||||
|
{
|
||||||
|
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||||
|
await customProvider.LogoutFromServerAsync();
|
||||||
|
NavigationManager.NavigateTo("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
private class VersionInfo
|
||||||
|
{
|
||||||
|
public string? Version { get; set; }
|
||||||
|
public string? Built { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<MudNavMenu>
|
||||||
|
<MudNavLink Href="/dashboard" Match="NavLinkMatch.All">Dashboard</MudNavLink>
|
||||||
|
<MudNavLink Href="/operations" Match="NavLinkMatch.Prefix">Operations</MudNavLink>
|
||||||
|
</MudNavMenu>
|
||||||
@@ -1,122 +1,93 @@
|
|||||||
@page "/collection"
|
@page "/collection"
|
||||||
@using QuantEngine.Web.Services
|
@attribute [Authorize]
|
||||||
|
@using QuantEngine.Web.Client.Services
|
||||||
@inject ApiClient ApiClient
|
@inject ApiClient ApiClient
|
||||||
@inject ILogger<Collection> Logger
|
@inject ILogger<Collection> Logger
|
||||||
|
|
||||||
<PageTitle>QuantEngine - Collection</PageTitle>
|
<PageTitle>QuantEngine - Collection</PageTitle>
|
||||||
|
|
||||||
<h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 600;">Data Collection</h1>
|
<MudText Typo="Typo.h4" Class="mb-2">Data Collection</MudText>
|
||||||
<p style="margin: 0 0 16px 0; color: var(--neutral-foreground-2); font-size: 14px;">
|
<MudText Typo="Typo.body2" Class="mb-4">KIS API data collection dashboard. API-first로만 동작합니다.</MudText>
|
||||||
KIS API data collection dashboard. Monitor runs, snapshots, and error trends.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Controls -->
|
<MudStack Row="true" Spacing="2" Class="mb-4">
|
||||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="8" Style="margin-bottom: 16px;">
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
|
||||||
<FluentButton Appearance="ButtonAppearance.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
|
@(IsProcessing ? "Running..." : "Start Collection")
|
||||||
@if (IsProcessing) { <span>Running...</span> } else { <span>Start Collection</span> }
|
</MudButton>
|
||||||
</FluentButton>
|
<MudButton Variant="Variant.Outlined" OnClick="@RefreshAsync" Disabled="@IsProcessing">Refresh</MudButton>
|
||||||
<FluentButton Appearance="ButtonAppearance.Default" OnClick="@RefreshAsync" Disabled="@IsProcessing">
|
</MudStack>
|
||||||
Refresh
|
|
||||||
</FluentButton>
|
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
@if (IsLoading)
|
@if (IsLoading)
|
||||||
{
|
{
|
||||||
<FluentStack Orientation="Orientation.Vertical" VerticalGap="16">
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
|
||||||
<FluentSkeleton Width="100%" Height="60px" />
|
|
||||||
<FluentSkeleton Width="100%" Height="200px" />
|
|
||||||
</FluentStack>
|
|
||||||
}
|
}
|
||||||
else if (DashboardState != null)
|
else if (DashboardState != null)
|
||||||
{
|
{
|
||||||
<!-- Summary Cards -->
|
<MudGrid Spacing="2" Class="mb-4">
|
||||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
<MudItem xs="12" sm="4">
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<div style="padding: 16px;">
|
<MudText Typo="Typo.caption">Last Run</MudText>
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Last Run</p>
|
<MudText Typo="Typo.h6">@(DashboardState.LastRunStatus ?? "N/A")</MudText>
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@(DashboardState.LastRunStatus ?? "N/A")</h3>
|
<MudText Typo="Typo.body2">@(DashboardState.LastFinishedAt ?? "Not finished")</MudText>
|
||||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">@(DashboardState.LastFinishedAt ?? "Not finished")</p>
|
</MudPaper>
|
||||||
</div>
|
</MudItem>
|
||||||
</FluentCard>
|
<MudItem xs="12" sm="4">
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<div style="padding: 16px;">
|
<MudText Typo="Typo.caption">Total Snapshots</MudText>
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Total Snapshots</p>
|
<MudText Typo="Typo.h6">@DashboardState.TotalSnapshots</MudText>
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@DashboardState.TotalSnapshots</h3>
|
</MudPaper>
|
||||||
</div>
|
</MudItem>
|
||||||
</FluentCard>
|
<MudItem xs="12" sm="4">
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<div style="padding: 16px;">
|
<MudText Typo="Typo.caption">Total Errors</MudText>
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Total Errors</p>
|
<MudText Typo="Typo.h6">@DashboardState.TotalErrors</MudText>
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@DashboardState.TotalErrors</h3>
|
</MudPaper>
|
||||||
</div>
|
</MudItem>
|
||||||
</FluentCard>
|
</MudGrid>
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<!-- Recent Errors -->
|
|
||||||
@if (DashboardState.RecentErrors.Count > 0)
|
@if (DashboardState.RecentErrors.Count > 0)
|
||||||
{
|
{
|
||||||
<FluentCard Style="margin-bottom: 16px;">
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
<div style="padding: 16px;">
|
<MudText Typo="Typo.h6" Class="mb-3">Recent Errors</MudText>
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Recent Errors</h3>
|
<MudTable Items="@DashboardState.RecentErrors" Dense="true" Hover="true">
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<HeaderContent>
|
||||||
<thead style="background: var(--neutral-subtle);">
|
<MudTh>Source</MudTh>
|
||||||
<tr>
|
<MudTh>Kind</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Source</th>
|
<MudTh>Ticker</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Kind</th>
|
<MudTh>Message</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Ticker</th>
|
</HeaderContent>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Message</th>
|
<RowTemplate>
|
||||||
</tr>
|
<MudTd DataLabel="Source">@context.SourceName</MudTd>
|
||||||
</thead>
|
<MudTd DataLabel="Kind">@context.ErrorKind</MudTd>
|
||||||
<tbody>
|
<MudTd DataLabel="Ticker">@context.Ticker</MudTd>
|
||||||
@foreach (var error in DashboardState.RecentErrors)
|
<MudTd DataLabel="Message">@context.ErrorMessage</MudTd>
|
||||||
{
|
</RowTemplate>
|
||||||
<tr>
|
</MudTable>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@error.SourceName</td>
|
</MudPaper>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@error.ErrorKind</td>
|
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@error.Ticker</td>
|
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@error.ErrorMessage</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Recent Runs -->
|
|
||||||
@if (RecentRuns != null && RecentRuns.Count > 0)
|
@if (RecentRuns != null && RecentRuns.Count > 0)
|
||||||
{
|
{
|
||||||
<FluentCard>
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<div style="padding: 16px;">
|
<MudText Typo="Typo.h6" Class="mb-3">Recent Runs</MudText>
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Recent Runs</h3>
|
<MudTable Items="@RecentRuns" Dense="true" Hover="true">
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<HeaderContent>
|
||||||
<thead style="background: var(--neutral-subtle);">
|
<MudTh>Run ID</MudTh>
|
||||||
<tr>
|
<MudTh>Status</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Run ID</th>
|
<MudTh>Started</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Status</th>
|
<MudTh>Finished</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Started</th>
|
<MudTh>Snapshots</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Finished</th>
|
<MudTh>Errors</MudTh>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Snapshots</th>
|
</HeaderContent>
|
||||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Errors</th>
|
<RowTemplate>
|
||||||
</tr>
|
<MudTd DataLabel="Run ID" Style="font-family: monospace; font-size: 12px;">@context.RunId</MudTd>
|
||||||
</thead>
|
<MudTd DataLabel="Status">@context.Status</MudTd>
|
||||||
<tbody>
|
<MudTd DataLabel="Started">@context.StartedAt</MudTd>
|
||||||
@foreach (var run in RecentRuns)
|
<MudTd DataLabel="Finished">@context.FinishedAt</MudTd>
|
||||||
{
|
<MudTd DataLabel="Snapshots">@context.TotalSnapshots</MudTd>
|
||||||
<tr>
|
<MudTd DataLabel="Errors">@context.TotalErrors</MudTd>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); font-family: monospace; font-size: 12px;">@run.RunId</td>
|
</RowTemplate>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@run.Status</td>
|
</MudTable>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); font-size: 12px;">@run.StartedAt</td>
|
</MudPaper>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); font-size: 12px;">@run.FinishedAt</td>
|
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@run.TotalSnapshots</td>
|
|
||||||
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@run.TotalErrors</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +108,6 @@ else if (DashboardState != null)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
DashboardState = await ApiClient.GetCollectionStateAsync();
|
DashboardState = await ApiClient.GetCollectionStateAsync();
|
||||||
|
|
||||||
var runsResponse = await ApiClient.GetCollectionRunsAsync(10);
|
var runsResponse = await ApiClient.GetCollectionRunsAsync(10);
|
||||||
RecentRuns = runsResponse?.Runs ?? new();
|
RecentRuns = runsResponse?.Runs ?? new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@page "/dashboard"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using QuantEngine.Core.Infrastructure
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
|
<PageTitle>Quant Engine - Dashboard</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-2">Quant Engine</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-4">운영 진입점입니다. 로그인 후 현재 스냅샷 상태와 리포트 경로만 표시합니다.</MudText>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-4">
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Operational Report</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@ReportStateLabel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">@ReportPath</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Sections</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@SectionCountLabel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Temp/operational_report.json</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Primary Route</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/operations" Class="mt-2">Open Operations</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-4">
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">Current State</MudText>
|
||||||
|
<MudStack Spacing="1">
|
||||||
|
<MudText Typo="Typo.body2">Status: <MudChip T="string" Color="@(ReportChipLabel == "READY" ? Color.Success : Color.Warning)" Variant="Variant.Filled">@ReportChipLabel</MudChip></MudText>
|
||||||
|
<MudText Typo="Typo.body2">Generated: @GeneratedAtLabel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Source: @SourceLabel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Decision feed: @DecisionFeedLabel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Factor feed: @FactorFeedLabel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Raw feed: @RawFeedLabel</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">Routing Notes</MudText>
|
||||||
|
<ul style="margin: 0; padding-left: 18px;">
|
||||||
|
<li>운영 데이터는 snapshot 우선입니다.</li>
|
||||||
|
<li>Excel/GAS 의존 문구는 제거 대상입니다.</li>
|
||||||
|
<li>숫자는 provenance 없으면 표시하지 않습니다.</li>
|
||||||
|
</ul>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">Coverage Summary</MudText>
|
||||||
|
@if (Sections.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning">DATA_MISSING: operational_report.json이 비어 있거나 아직 생성되지 않았습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="@Sections" Dense="true" Hover="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Title</MudTh>
|
||||||
|
<MudTh>Preview</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||||
|
<MudTd DataLabel="Title">@context.Title</MudTd>
|
||||||
|
<MudTd DataLabel="Preview">@context.Preview</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly List<OperationalReportSection> Sections = new();
|
||||||
|
private string ReportStateLabel = "DATA_MISSING";
|
||||||
|
private string ReportChipLabel = "DATA_MISSING";
|
||||||
|
private string SectionCountLabel = "0";
|
||||||
|
private string GeneratedAtLabel = "n/a";
|
||||||
|
private string SourceLabel = "n/a";
|
||||||
|
private string DecisionFeedLabel = "DISCONNECTED";
|
||||||
|
private string FactorFeedLabel = "DISCONNECTED";
|
||||||
|
private string RawFeedLabel = "DISCONNECTED";
|
||||||
|
private string ReportPath = "n/a";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
|
||||||
|
if (report != null)
|
||||||
|
{
|
||||||
|
Sections.Clear();
|
||||||
|
Sections.AddRange(report.Sections);
|
||||||
|
SectionCountLabel = report.SectionCount.ToString();
|
||||||
|
GeneratedAtLabel = report.GeneratedAt;
|
||||||
|
SourceLabel = report.SourceJson;
|
||||||
|
ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
|
||||||
|
ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ReportStateLabel = "DATA_MISSING";
|
||||||
|
ReportChipLabel = "DATA_MISSING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@page "/login"
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
@layout AuthLayout
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
|
<PageTitle>로그인 - QuantEngine</PageTitle>
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.False" Class="login-shell">
|
||||||
|
<MudPaper Class="login-card pa-8" Elevation="10">
|
||||||
|
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="mb-6">
|
||||||
|
<MudAvatar Size="Size.Large" Color="Color.Primary">Q</MudAvatar>
|
||||||
|
<MudText Typo="Typo.h4">QuantEngine</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Align="Align.Center">은퇴자산포트폴리오 투자 관리 시스템</MudText>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudTextField Label="관리자 아이디" @bind-Value="Username" Variant="Variant.Outlined" Immediate="true" AutoFocus="true" />
|
||||||
|
<MudTextField Label="비밀번호" @bind-Value="Password" Variant="Variant.Outlined" InputType="InputType.Password" Immediate="true" />
|
||||||
|
<MudCheckBox T="bool" @bind-Checked="RememberUsername" Color="Color.Primary" Label="아이디 저장" />
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error">@ErrorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true" Disabled="@IsSubmitting" OnClick="HandleLoginAsync">
|
||||||
|
@(IsSubmitting ? "인증 중..." : "로그인")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(0, 242, 254, 0.08), transparent 30%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(79, 172, 254, 0.1), transparent 35%),
|
||||||
|
linear-gradient(135deg, #090a15 0%, #12142d 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string Username { get; set; } = string.Empty;
|
||||||
|
private string Password { get; set; } = string.Empty;
|
||||||
|
private string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
private bool IsSubmitting { get; set; } = false;
|
||||||
|
private bool RememberUsername { get; set; } = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||||
|
var remembered = await customProvider.GetRememberedUsernameAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(remembered))
|
||||||
|
{
|
||||||
|
Username = remembered;
|
||||||
|
RememberUsername = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LoginResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
public string? ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleLoginAsync()
|
||||||
|
{
|
||||||
|
ErrorMessage = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
|
||||||
|
{
|
||||||
|
ErrorMessage = "아이디와 비밀번호를 모두 입력해 주세요.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsSubmitting = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.PostAsJsonAsync("api/auth/login", new { Username, Password });
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var auth = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||||
|
if (auth is null || string.IsNullOrWhiteSpace(auth.AccessToken))
|
||||||
|
{
|
||||||
|
ErrorMessage = "로그인 응답이 유효하지 않습니다.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||||
|
await customProvider.MarkUserAsAuthenticatedAsync(auth.Username ?? Username, auth.AccessToken, auth.Role ?? "Admin", RememberUsername);
|
||||||
|
NavigationManager.NavigateTo("/dashboard");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorMessage = $"로그인 중 오류가 발생했습니다: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@page "/operations"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using QuantEngine.Core.Infrastructure
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
|
<PageTitle>QuantEngine - Operations</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-2">Operational Report</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-4">Temp/operational_report.json만 읽는 운영 고정 화면입니다.</MudText>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-4">
|
||||||
|
<MudItem xs="12" sm="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Schema</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@SchemaVersion</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Sections</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@SectionCountLabel</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Source</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@SourceJson</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">Generated</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@GeneratedAt</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-4">
|
||||||
|
@foreach (var section in HighlightSections)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="3" @key="section.Name">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.caption">@(section.Name)</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@(section.Title)</MudText>
|
||||||
|
<MudText Typo="Typo.body2">@(section.Preview)</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">Report Health</MudText>
|
||||||
|
<MudStack Spacing="1">
|
||||||
|
<MudText Typo="Typo.body2">Status: <MudChip T="string" Color="@(HealthLabel == "PASS" ? Color.Success : Color.Warning)" Variant="Variant.Filled">@HealthLabel</MudChip></MudText>
|
||||||
|
<MudText Typo="Typo.body2">Path: @ReportPath</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Sections rendered: @RenderedSectionCountLabel</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">Sections</MudText>
|
||||||
|
@if (Sections.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning">DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="@Sections" Dense="true" Hover="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Title</MudTh>
|
||||||
|
<MudTh>Preview</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||||
|
<MudTd DataLabel="Title">@context.Title</MudTd>
|
||||||
|
<MudTd DataLabel="Preview">@context.Preview</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly List<OperationalReportSection> Sections = new();
|
||||||
|
private readonly List<OperationalReportSection> HighlightSections = new();
|
||||||
|
private string SchemaVersion = "n/a";
|
||||||
|
private string SourceJson = "n/a";
|
||||||
|
private string GeneratedAt = "n/a";
|
||||||
|
private string SectionCountLabel = "0";
|
||||||
|
private string RenderedSectionCountLabel = "0";
|
||||||
|
private string HealthLabel = "DATA_MISSING";
|
||||||
|
private string ReportPath = "n/a";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
|
||||||
|
if (report != null)
|
||||||
|
{
|
||||||
|
SchemaVersion = report.SchemaVersion;
|
||||||
|
SourceJson = report.SourceJson;
|
||||||
|
GeneratedAt = report.GeneratedAt;
|
||||||
|
|
||||||
|
Sections.Clear();
|
||||||
|
Sections.AddRange(report.Sections);
|
||||||
|
|
||||||
|
HighlightSections.Clear();
|
||||||
|
HighlightSections.AddRange(Sections.Take(4));
|
||||||
|
|
||||||
|
SectionCountLabel = report.SectionCount.ToString();
|
||||||
|
RenderedSectionCountLabel = Sections.Count.ToString();
|
||||||
|
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
HealthLabel = "DATA_MISSING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using QuantEngine.Web.Client.Services;
|
||||||
|
using QuantEngine.Web.Client.Infrastructure;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
// Register LocalStorage for cross-platform session persistence
|
||||||
|
builder.Services.AddScoped<LocalStorageService>();
|
||||||
|
|
||||||
|
// Authentication setup in WebAssembly client
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
||||||
|
|
||||||
|
// HttpClient register (API-First standard)
|
||||||
|
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
|
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-preview.2.25120.18" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0-preview.2.25120.18" />
|
||||||
|
<PackageReference Include="MudBlazor" Version="8.6.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("login");
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-9
@@ -2,19 +2,16 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
namespace QuantEngine.Web.Services;
|
namespace QuantEngine.Web.Client.Services;
|
||||||
|
|
||||||
public class ApiClient
|
public class ApiClient
|
||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<ApiClient> _logger;
|
private readonly ILogger<ApiClient> _logger;
|
||||||
private string BaseUrl { get; set; }
|
|
||||||
|
|
||||||
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
|
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
BaseUrl = "http://localhost:5001"; // Default for Blazor Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection API Methods
|
// Collection API Methods
|
||||||
@@ -23,7 +20,7 @@ public class ApiClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>($"{BaseUrl}/api/collection/state");
|
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -36,7 +33,7 @@ public class ApiClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"{BaseUrl}/api/collection/runs?limit={limit}");
|
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"api/collection/runs?limit={limit}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -49,7 +46,7 @@ public class ApiClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"{BaseUrl}/api/collection/runs/{runId}/snapshots");
|
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"api/collection/runs/{runId}/snapshots");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -62,7 +59,7 @@ public class ApiClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"{BaseUrl}/api/collection/runs/{runId}/errors?limit={limit}");
|
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"api/collection/runs/{runId}/errors?limit={limit}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -75,7 +72,7 @@ public class ApiClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _http.PostAsJsonAsync($"{BaseUrl}/api/collection/run", new { });
|
var response = await _http.PostAsJsonAsync("api/collection/run", new { });
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return await response.Content.ReadFromJsonAsync<CollectionRunStartResponse>();
|
return await response.Content.ReadFromJsonAsync<CollectionRunStartResponse>();
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace QuantEngine.Web.Client.Services
|
||||||
|
{
|
||||||
|
public class LocalStorageService
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime _js;
|
||||||
|
|
||||||
|
public LocalStorageService(IJSRuntime js)
|
||||||
|
{
|
||||||
|
_js = js;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetAsync<T>(string key, T value)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(value);
|
||||||
|
await _js.InvokeVoidAsync("localStorage.setItem", key, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T?> GetAsync<T>(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
|
||||||
|
if (string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
return JsonSerializer.Deserialize<T>(json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string key)
|
||||||
|
{
|
||||||
|
await _js.InvokeVoidAsync("localStorage.removeItem", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@
|
|||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
@using MudBlazor
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components.Icons
|
@using QuantEngine.Web.Client
|
||||||
@using QuantEngine.Web
|
@using QuantEngine.Web.Client.Pages
|
||||||
@using QuantEngine.Web.Components
|
@using QuantEngine.Web.Client.Layout
|
||||||
@using QuantEngine.Web.Components.Layout
|
@using QuantEngine.Web.Client.Infrastructure
|
||||||
@using QuantEngine.Web.Services
|
@using QuantEngine.Web.Client.Services
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/quant/" />
|
<base href="/" />
|
||||||
<ResourcePreloader />
|
<ResourcePreloader />
|
||||||
<!-- Fluent UI CSS -->
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||||
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/fluent-components.css" rel="stylesheet" />
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<HeadOutlet />
|
<link rel="alternate icon" type="image/png" href="favicon.png" />
|
||||||
|
<HeadOutlet @rendermode="InteractiveWebAssembly" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<FluentDesignSystemProvider>
|
<MudThemeProvider />
|
||||||
<Routes />
|
<MudDialogProvider />
|
||||||
<ReconnectModal />
|
<MudSnackbarProvider />
|
||||||
</FluentDesignSystemProvider>
|
<Routes @rendermode="InteractiveWebAssembly" />
|
||||||
|
<ReconnectModal />
|
||||||
|
|
||||||
<!-- Fluent UI JS -->
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/fluent-components.js"></script>
|
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
@inherits LayoutComponentBase
|
|
||||||
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment WebHostEnvironment
|
|
||||||
@using System.IO
|
|
||||||
@using System.Text.Json
|
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
|
||||||
|
|
||||||
<FluentStack Orientation="Orientation.Vertical" Class="h-100 w-100">
|
|
||||||
<!-- Header -->
|
|
||||||
<FluentHeader>
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center"
|
|
||||||
Style="width: 100%; padding: 8px 16px; gap: 16px;">
|
|
||||||
<FluentButton OnClick="@(() => navOpen = !navOpen)"
|
|
||||||
Title="Toggle Navigation"
|
|
||||||
Style="background: transparent; border: none; cursor: pointer;">
|
|
||||||
☰
|
|
||||||
</FluentButton>
|
|
||||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600;">QuantEngine v@appVersion</h1>
|
|
||||||
</FluentStack>
|
|
||||||
</FluentHeader>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" Class="flex-1" Style="overflow: hidden;">
|
|
||||||
<!-- Navigation Sidebar -->
|
|
||||||
@if (navOpen)
|
|
||||||
{
|
|
||||||
<nav style="width: 240px; background: var(--neutral-layer-1); border-right: 1px solid var(--neutral-stroke-1); padding: 12px; overflow-y: auto;">
|
|
||||||
<NavMenu />
|
|
||||||
<div style="margin-top: auto; padding-top: 12px; border-top: 1px solid var(--neutral-stroke-1); margin-top: 12px; font-size: 11px; color: var(--neutral-foreground-3); line-height: 1.5;">
|
|
||||||
<div style="font-weight: 500; margin-bottom: 2px;">QuantEngine v@appVersion</div>
|
|
||||||
<div style="font-size: 10px; opacity: 0.85;">배포: @buildTime</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Page Content -->
|
|
||||||
<FluentStack Orientation="Orientation.Vertical" Class="flex-1" Style="overflow-y: auto; padding: 24px;">
|
|
||||||
@Body
|
|
||||||
</FluentStack>
|
|
||||||
</FluentStack>
|
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
<p>An unhandled error has occurred.</p>
|
|
||||||
<a href="." class="btn btn-primary">Reload</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.h-100 {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.w-100 {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool navOpen = true;
|
|
||||||
private string appVersion = "Local Debug";
|
|
||||||
private string buildTime = "N/A";
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var versionFilePath = Path.Combine(WebHostEnvironment.WebRootPath, "version.json");
|
|
||||||
if (File.Exists(versionFilePath))
|
|
||||||
{
|
|
||||||
var jsonContent = File.ReadAllText(versionFilePath);
|
|
||||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonContent);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
if (root.TryGetProperty("version", out var versionProp))
|
|
||||||
{
|
|
||||||
appVersion = versionProp.GetString() ?? "Local Debug";
|
|
||||||
}
|
|
||||||
if (root.TryGetProperty("built", out var builtProp))
|
|
||||||
{
|
|
||||||
buildTime = builtProp.GetString() ?? "N/A";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fail-safe default fallback values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
|
||||||
|
|
||||||
<FluentNavMenu>
|
|
||||||
<FluentNavLink Href="/" Match="NavLinkMatch.All">
|
|
||||||
Dashboard
|
|
||||||
</FluentNavLink>
|
|
||||||
<FluentNavLink Href="/operations" Match="NavLinkMatch.Prefix">
|
|
||||||
Operations
|
|
||||||
</FluentNavLink>
|
|
||||||
</FluentNavMenu>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
@page "/"
|
|
||||||
@using QuantEngine.Core.Infrastructure
|
|
||||||
@inject IWebHostEnvironment Environment
|
|
||||||
|
|
||||||
<PageTitle>Quant Engine - Dashboard</PageTitle>
|
|
||||||
|
|
||||||
<h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 600;">Quant Engine</h1>
|
|
||||||
<p style="margin: 0 0 16px 0; color: var(--neutral-foreground-2); font-size: 14px;">
|
|
||||||
루트 화면은 운영 진입점입니다. 가짜 성과 수치 없이 현재 스냅샷 상태와 리포트 경로만 보여줍니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Top 3 Cards -->
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
|
||||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Operational Report</p>
|
|
||||||
<h3 style="margin: 0; font-size: 20px; font-weight: 600;">@ReportStateLabel</h3>
|
|
||||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">@ReportPath</p>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Sections</p>
|
|
||||||
<h3 style="margin: 0; font-size: 20px; font-weight: 600;">@SectionCountLabel</h3>
|
|
||||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">Temp/operational_report.json</p>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Primary Route</p>
|
|
||||||
<FluentButton Appearance="ButtonAppearance.Primary" Href="/operations" Style="margin-top: 8px;">
|
|
||||||
Open Operations
|
|
||||||
</FluentButton>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<!-- Current State & Routing Notes -->
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
|
||||||
<FluentCard Style="flex: 2; min-width: 300px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Current State</h3>
|
|
||||||
<FluentStack Orientation="Orientation.Vertical" VerticalGap="8">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Status:</strong> <FluentBadge Appearance="BadgeAppearance.Filled">@ReportChipLabel</FluentBadge></p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Generated:</strong> @GeneratedAtLabel</p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Source:</strong> @SourceLabel</p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Decision feed:</strong> @DecisionFeedLabel</p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Factor feed:</strong> @FactorFeedLabel</p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Raw feed:</strong> @RawFeedLabel</p>
|
|
||||||
</FluentStack>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
|
|
||||||
<FluentCard Style="flex: 1; min-width: 250px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Routing Notes</h3>
|
|
||||||
<ul style="margin: 0; padding-left: 16px; font-size: 14px;">
|
|
||||||
<li>운영 데이터는 snapshot 우선입니다.</li>
|
|
||||||
<li>Excel/GAS 의존 문구는 운영 경로에서 제거 대상입니다.</li>
|
|
||||||
<li>숫자는 provenance 없으면 표시하지 않습니다.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<!-- Coverage Summary -->
|
|
||||||
<FluentCard>
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Coverage Summary</h3>
|
|
||||||
@if (Sections.Count == 0)
|
|
||||||
{
|
|
||||||
<div style="padding: 12px; background: var(--warning-background-1); border: 1px solid var(--warning-stroke-1); border-radius: 4px; color: var(--warning-foreground-1); font-size: 14px;">
|
|
||||||
DATA_MISSING: operational_report.json이 비어 있거나 아직 생성되지 않았습니다.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<FluentDataGrid Items="@Sections.AsQueryable()">
|
|
||||||
<PropertyColumn Property="@(x => x.Name)" Title="Name" />
|
|
||||||
<PropertyColumn Property="@(x => x.Title)" Title="Title" />
|
|
||||||
<PropertyColumn Property="@(x => x.Preview)" Title="Preview" />
|
|
||||||
</FluentDataGrid>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private readonly List<OperationalReportSection> Sections = new();
|
|
||||||
private string ReportStateLabel = "DATA_MISSING";
|
|
||||||
private string ReportChipLabel = "DATA_MISSING";
|
|
||||||
private string SectionCountLabel = "0";
|
|
||||||
private string GeneratedAtLabel = "n/a";
|
|
||||||
private string SourceLabel = "n/a";
|
|
||||||
private string DecisionFeedLabel = "DISCONNECTED";
|
|
||||||
private string FactorFeedLabel = "DISCONNECTED";
|
|
||||||
private string RawFeedLabel = "DISCONNECTED";
|
|
||||||
private string ReportPath = "n/a";
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
|
|
||||||
var report = OperationalReportLoader.Load(ReportPath);
|
|
||||||
Sections.AddRange(report.Sections);
|
|
||||||
SectionCountLabel = report.SectionCount.ToString();
|
|
||||||
GeneratedAtLabel = report.GeneratedAt;
|
|
||||||
SourceLabel = report.SourceJson;
|
|
||||||
ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
|
|
||||||
ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
@page "/operations"
|
|
||||||
@using QuantEngine.Core.Infrastructure
|
|
||||||
@inject IWebHostEnvironment Environment
|
|
||||||
|
|
||||||
<PageTitle>Quant Engine - Operations</PageTitle>
|
|
||||||
|
|
||||||
<h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 600;">Operational Report</h1>
|
|
||||||
<p style="margin: 0 0 16px 0; color: var(--neutral-foreground-2); font-size: 14px;">
|
|
||||||
이 페이지는 `Temp/operational_report.json`만 읽습니다. DB 연결과 무관하게 동일한 결과를 보여주는 운영 고정 화면입니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Metadata Cards -->
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Schema</p>
|
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@SchemaVersion</h3>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Sections</p>
|
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@SectionCountLabel</h3>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Source</p>
|
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@SourceJson</h3>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Generated</p>
|
|
||||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@GeneratedAt</h3>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<!-- Highlight Sections Grid -->
|
|
||||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
|
||||||
@foreach (var section in HighlightSections)
|
|
||||||
{
|
|
||||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 4px 0; color: var(--neutral-foreground-2); font-size: 12px;">@(section.Name)</p>
|
|
||||||
<h3 style="margin: 4px 0; font-size: 16px; font-weight: 600;">@(section.Title)</h3>
|
|
||||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">@(section.Preview)</p>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
}
|
|
||||||
</FluentStack>
|
|
||||||
|
|
||||||
<!-- Report Health -->
|
|
||||||
<FluentCard Style="margin-bottom: 16px;">
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Report Health</h3>
|
|
||||||
<FluentStack Orientation="Orientation.Vertical" VerticalGap="8">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Status:</strong> <FluentBadge Appearance="BadgeAppearance.Filled">@HealthLabel</FluentBadge></p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Path:</strong> @ReportPath</p>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Sections rendered:</strong> @RenderedSectionCountLabel</p>
|
|
||||||
</FluentStack>
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
|
|
||||||
<!-- Sections Table -->
|
|
||||||
<FluentCard>
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Sections</h3>
|
|
||||||
@if (Sections.Count == 0)
|
|
||||||
{
|
|
||||||
<div style="padding: 12px; background: var(--warning-background-1); border: 1px solid var(--warning-stroke-1); border-radius: 4px; color: var(--warning-foreground-1); font-size: 14px;">
|
|
||||||
DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<FluentDataGrid Items="@Sections.AsQueryable()">
|
|
||||||
<PropertyColumn Property="@(x => x.Name)" Title="Name" />
|
|
||||||
<PropertyColumn Property="@(x => x.Title)" Title="Title" />
|
|
||||||
<PropertyColumn Property="@(x => x.Preview)" Title="Preview" />
|
|
||||||
</FluentDataGrid>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</FluentCard>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private readonly List<OperationalReportSection> Sections = new();
|
|
||||||
private readonly List<OperationalReportSection> HighlightSections = new();
|
|
||||||
private string SchemaVersion = "n/a";
|
|
||||||
private string SourceJson = "n/a";
|
|
||||||
private string GeneratedAt = "n/a";
|
|
||||||
private string SectionCountLabel = "0";
|
|
||||||
private string RenderedSectionCountLabel = "0";
|
|
||||||
private string HealthLabel = "DATA_MISSING";
|
|
||||||
private string ReportPath = "n/a";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
|
|
||||||
|
|
||||||
var report = OperationalReportLoader.Load(ReportPath);
|
|
||||||
SchemaVersion = report.SchemaVersion;
|
|
||||||
SourceJson = report.SourceJson;
|
|
||||||
GeneratedAt = report.GeneratedAt;
|
|
||||||
Sections.AddRange(report.Sections);
|
|
||||||
|
|
||||||
HighlightSections.Clear();
|
|
||||||
HighlightSections.AddRange(Sections.Take(4));
|
|
||||||
|
|
||||||
SectionCountLabel = report.SectionCount.ToString();
|
|
||||||
RenderedSectionCountLabel = Sections.Count.ToString();
|
|
||||||
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
@using QuantEngine.Web.Client
|
||||||
<Found Context="routeData">
|
@using QuantEngine.Web.Client.Pages
|
||||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
@using QuantEngine.Web.Client.Layout
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
|
||||||
</Found>
|
<CascadingAuthenticationState>
|
||||||
</Router>
|
<Router AppAssembly="typeof(Dashboard).Assembly" NotFoundPage="typeof(NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
|
||||||
|
<NotAuthorized>
|
||||||
|
<RedirectToLogin />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using System.Net.Http
|
@using System.Net.Http
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
@using MudBlazor
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components.Icons
|
|
||||||
@using QuantEngine.Web
|
@using QuantEngine.Web
|
||||||
@using QuantEngine.Web.Components
|
@using QuantEngine.Web.Components
|
||||||
@using QuantEngine.Web.Components.Layout
|
@using QuantEngine.Web.Components.Layout
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using QuantEngine.Web.Infrastructure
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ public static class CollectionEndpoints
|
|||||||
var runId = Guid.NewGuid().ToString("N");
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
var now = DateTime.UtcNow.ToString("o");
|
var now = DateTime.UtcNow.ToString("o");
|
||||||
|
|
||||||
var body = await request.ReadAsAsync<CollectionRunRequest>();
|
var body = await request.ReadFromJsonAsync<CollectionRunRequest>();
|
||||||
var account = body?.Account ?? "real";
|
var account = body?.Account ?? "real";
|
||||||
var tickers = body?.Tickers ?? new List<string> { "005930", "000660" };
|
var tickers = body?.Tickers ?? new List<string> { "005930", "000660" };
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
using QuantEngine.Web.Components;
|
using QuantEngine.Web.Components;
|
||||||
using QuantEngine.Web.Services;
|
|
||||||
using QuantEngine.Infrastructure.Data;
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using QuantEngine.Web.Infrastructure;
|
||||||
using QuantEngine.Infrastructure.Repositories;
|
using QuantEngine.Infrastructure.Repositories;
|
||||||
using QuantEngine.Infrastructure.Services;
|
using QuantEngine.Infrastructure.Services;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using static QuantEngine.Application.Services.DataCollectionService;
|
using static QuantEngine.Application.Services.DataCollectionService;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using QuantEngine.Web.Infrastructure;
|
using QuantEngine.Web.Client.Infrastructure;
|
||||||
|
using QuantEngine.Web.Client.Services;
|
||||||
using QuantEngine.Web.Endpoints;
|
using QuantEngine.Web.Endpoints;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using QuantEngine.Core.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MudBlazor.Services;
|
||||||
|
|
||||||
// Serilog Configuration with Telegram Sink
|
// Serilog Configuration with Telegram Sink
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
@@ -24,15 +32,32 @@ builder.Host.UseSerilog();
|
|||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveWebAssemblyComponents();
|
||||||
|
|
||||||
// Fluent UI Services
|
// Authentication and Custom State Provider (Shared client components)
|
||||||
builder.Services.AddFluentUIComponents();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddAuthentication("QuantAdminScheme")
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, QuantAdminAuthHandler>("QuantAdminScheme", _ => { });
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
builder.Services.AddScoped<LocalStorageService>();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
|
||||||
|
builder.Services.AddMudServices();
|
||||||
|
|
||||||
// PostgreSQL Dapper Setup
|
// PostgreSQL Dapper Setup
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
var configuredConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||||
?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;";
|
var fallbackConnectionString = "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=CHANGE_ME;Search Path=quantengine;";
|
||||||
|
var connectionString = string.IsNullOrWhiteSpace(configuredConnectionString) || configuredConnectionString.Contains("Password=;", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? fallbackConnectionString
|
||||||
|
: configuredConnectionString;
|
||||||
|
var configuredDatabase = new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database;
|
||||||
|
if (!string.Equals(configuredDatabase, "quantenginedb", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("QuantEngine must use the quantenginedb PostgreSQL database.");
|
||||||
|
}
|
||||||
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
|
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
|
||||||
|
builder.Services.AddSingleton<DbMigrator>();
|
||||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||||
@@ -49,18 +74,25 @@ builder.Services.AddHttpClient<ApiClient>();
|
|||||||
builder.Services.AddScoped<ApiClient>();
|
builder.Services.AddScoped<ApiClient>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
var adminSettings = app.Configuration.GetSection("AdminSettings");
|
||||||
|
var adminUsername = adminSettings["Username"] ?? "admin";
|
||||||
|
var adminPassword = adminSettings["Password"] ?? string.Empty;
|
||||||
|
|
||||||
// Initialize database tables (PostgreSQL-backed repositories)
|
// Initialize database tables (PostgreSQL-backed repositories)
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
|
||||||
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
||||||
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
||||||
|
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
migrator.Migrate();
|
||||||
// Ensure tables exist on startup
|
// Ensure tables exist on startup
|
||||||
await tokenCache.GetCachedTokenAsync("_init_test_");
|
await tokenCache.GetCachedTokenAsync("_init_test_");
|
||||||
await collectionRepo.GetDashboardStateAsync();
|
await collectionRepo.GetDashboardStateAsync();
|
||||||
|
await workspaceRepo.GetAccountsAsync();
|
||||||
Log.Information("Database tables initialized successfully");
|
Log.Information("Database tables initialized successfully");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -69,9 +101,6 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable reverse proxy subpath mapping
|
|
||||||
app.UsePathBase("/quant");
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -88,12 +117,186 @@ app.UseStatusCodePages(async ctx =>
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
|
|
||||||
|
app.MapGet("/", () => Results.Redirect("/login"));
|
||||||
|
|
||||||
// Collection API Endpoints (must be before MapRazorComponents)
|
// Collection API Endpoints (must be before MapRazorComponents)
|
||||||
app.MapCollectionEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
|
|
||||||
|
// Login API (API-First for Blazor WASM client authentication)
|
||||||
|
app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository workspaceRepo) =>
|
||||||
|
{
|
||||||
|
static string? ReadString(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return property.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = ReadString(payload, "Username", "username");
|
||||||
|
var password = ReadString(payload, "Password", "password");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { success = false, error = "missing_credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = await workspaceRepo.GetAccountByUsernameAsync(username.Trim());
|
||||||
|
if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||||
|
if (!string.Equals(account.PasswordHash, passwordHash, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawToken = Guid.NewGuid().ToString("N");
|
||||||
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var expiresAt = now.AddDays(7);
|
||||||
|
|
||||||
|
await workspaceRepo.UpsertSessionAsync(new WorkspaceSession
|
||||||
|
{
|
||||||
|
SessionTokenHash = tokenHash,
|
||||||
|
Username = account.Username,
|
||||||
|
Role = account.Role,
|
||||||
|
CreatedAt = now.ToString("O"),
|
||||||
|
ExpiresAt = expiresAt.ToString("O"),
|
||||||
|
RevokedAt = null
|
||||||
|
});
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
username = account.Username,
|
||||||
|
role = account.Role,
|
||||||
|
accessToken = rawToken,
|
||||||
|
expiresAt = expiresAt.ToString("O")
|
||||||
|
});
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
||||||
|
{
|
||||||
|
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||||
|
var session = await workspaceRepo.GetSessionByTokenHashAsync(tokenHash);
|
||||||
|
if (session is null || !string.IsNullOrWhiteSpace(session.RevokedAt) || DateTimeOffset.TryParse(session.ExpiresAt, out var expiresAt) && expiresAt <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { authenticated = true, username = session.Username, role = session.Role });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/auth/logout", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
||||||
|
{
|
||||||
|
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||||
|
await workspaceRepo.RevokeSessionAsync(tokenHash, DateTimeOffset.UtcNow.ToString("O"));
|
||||||
|
return Results.Ok(new { success = true });
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/api/auth/admin/reset-password", async (HttpContext context, JsonElement payload, IWorkspaceRepository workspaceRepo) =>
|
||||||
|
{
|
||||||
|
static string? ReadString(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return property.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = ReadString(payload, "adminUsername", "AdminUsername", "username", "Username");
|
||||||
|
var password = ReadString(payload, "adminPassword", "AdminPassword", "password", "Password");
|
||||||
|
var targetUsername = ReadString(payload, "targetUsername", "TargetUsername", "usernameToReset", "UsernameToReset");
|
||||||
|
var newPassword = ReadString(payload, "newPassword", "NewPassword");
|
||||||
|
|
||||||
|
if (!string.Equals(username, adminUsername, StringComparison.Ordinal) || !string.Equals(password, adminPassword, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(targetUsername) || string.IsNullOrWhiteSpace(newPassword))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { success = false, error = "missing_target_or_password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = await workspaceRepo.GetAccountByUsernameAsync(targetUsername.Trim());
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { success = false, error = "account_not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(newPassword)));
|
||||||
|
account.PasswordHash = passwordHash;
|
||||||
|
account.UpdatedAt = DateTimeOffset.UtcNow.ToString("O");
|
||||||
|
var updated = await workspaceRepo.UpsertAccountAsync(account);
|
||||||
|
if (!updated)
|
||||||
|
{
|
||||||
|
return Results.StatusCode(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
username = account.Username,
|
||||||
|
updatedAt = account.UpdatedAt
|
||||||
|
});
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
// Operational Report serving API (WASM safe file loading substitute)
|
||||||
|
app.MapGet("/api/operational-report", async (IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var path = Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { gate = "FAIL", error = "operational_report_missing" });
|
||||||
|
}
|
||||||
|
var json = await File.ReadAllTextAsync(path);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
return Results.Ok(doc.RootElement);
|
||||||
|
});
|
||||||
|
|
||||||
app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) =>
|
app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) =>
|
||||||
{
|
{
|
||||||
var rows = await reader.ReadAsync(domain, limit ?? 500);
|
var rows = await reader.ReadAsync(domain, limit ?? 500);
|
||||||
@@ -138,7 +341,30 @@ app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload,
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveWebAssemblyRenderMode()
|
||||||
|
.AddAdditionalAssemblies(typeof(QuantEngine.Web.Client._Imports).Assembly);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
internal sealed class QuantAdminAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public QuantAdminAuthHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,21 @@
|
|||||||
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
|
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
||||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||||
|
<ProjectReference Include="Client\QuantEngine.Web.Client.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="5.0.0-rc.4-26177.1" />
|
<PackageReference Include="MudBlazor" Version="8.6.0" />
|
||||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="5.0.0-rc.4-26177.1" />
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.0-preview.2.25120.18" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Exclude client project files from server build to avoid duplicate compilations -->
|
||||||
|
<Compile Remove="Client\**" />
|
||||||
|
<Content Remove="Client\**" />
|
||||||
|
<EmbeddedResource Remove="Client\**" />
|
||||||
|
<None Remove="Client\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=;Search Path=quantengine;"
|
"DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=;Search Path=quantengine;"
|
||||||
|
},
|
||||||
|
"AdminSettings": {
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "quant123!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="QuantEngine favicon">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#00f2fe"/>
|
||||||
|
<stop offset="100%" stop-color="#4facfe"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="16" fill="#0b1020"/>
|
||||||
|
<circle cx="32" cy="32" r="22" fill="url(#g)" opacity="0.18"/>
|
||||||
|
<path d="M20 24h12c7.2 0 12 4.5 12 10.8 0 4.6-2.4 8.1-6.5 9.8L45 44h-8l-6.2-6.2H28V44h-8V24Zm8 6v4h4.6c2 0 3.4-.8 3.4-2.1 0-1.4-1.4-1.9-3.3-1.9H28Z" fill="url(#g)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 602 B |
Binary file not shown.
|
After Width: | Height: | Size: 679 KiB |
@@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
@@ -15,6 +15,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Core.Tests", "Q
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Tools", "QuantEngine.Tools\QuantEngine.Tools.csproj", "{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Tools", "QuantEngine.Tools\QuantEngine.Tools.csproj", "{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}"
|
||||||
EndProject
|
EndProject
|
||||||
|
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Web.Client", "QuantEngine.Web\Client\QuantEngine.Web.Client.csproj", "{C5F2F3BD-1258-40FC-803A-EE7EEC928107}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -97,8 +100,21 @@ Global
|
|||||||
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.Build.0 = Release|Any CPU
|
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.ActiveCfg = Release|Any CPU
|
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.Build.0 = Release|Any CPU
|
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# Quant Engine Shadow Copy Hot Deploy Script
|
# Quant Engine CI-only hot deploy script
|
||||||
# To be executed on Hz-Prod-01 Remote Server
|
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${CI_DEPLOY:-0}" != "1" ]; then
|
||||||
|
echo "ERROR: CI-only deployment policy. Use the Gitea workflow to deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
DEPLOY_BASE="/home/kjh2064/deployments"
|
DEPLOY_BASE="/home/kjh2064/deployments"
|
||||||
ACTIVE_LINK="/home/kjh2064/quantengine_active"
|
ACTIVE_LINK="/home/kjh2064/quantengine_active"
|
||||||
@@ -15,11 +19,9 @@ echo "========================================="
|
|||||||
echo "Starting Shadow Copy Hot Deploy [${TIMESTAMP}]"
|
echo "Starting Shadow Copy Hot Deploy [${TIMESTAMP}]"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
|
|
||||||
# 1. Ensure directories exist
|
|
||||||
mkdir -p "${DEPLOY_BASE}"
|
mkdir -p "${DEPLOY_BASE}"
|
||||||
mkdir -p "${TARGET_DIR}"
|
mkdir -p "${TARGET_DIR}"
|
||||||
|
|
||||||
# 2. Extract build artifact to unique shadow directory
|
|
||||||
if [ -f "${TMP_ARCHIVE}" ]; then
|
if [ -f "${TMP_ARCHIVE}" ]; then
|
||||||
echo "Extracting build artifact to ${TARGET_DIR}..."
|
echo "Extracting build artifact to ${TARGET_DIR}..."
|
||||||
tar -xzf "${TMP_ARCHIVE}" -C "${TARGET_DIR}"
|
tar -xzf "${TMP_ARCHIVE}" -C "${TARGET_DIR}"
|
||||||
@@ -29,15 +31,12 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Swap symbolic link atomically
|
|
||||||
echo "Swapping symbolic link dynamically..."
|
echo "Swapping symbolic link dynamically..."
|
||||||
ln -sfn "${TARGET_DIR}" "${ACTIVE_LINK}"
|
ln -sfn "${TARGET_DIR}" "${ACTIVE_LINK}"
|
||||||
|
|
||||||
# 4. Restart Systemd service (requires passwordless sudo reload or specific policy)
|
|
||||||
echo "Restarting Systemd service..."
|
echo "Restarting Systemd service..."
|
||||||
sudo systemctl restart quantengine
|
sudo systemctl restart quantengine
|
||||||
|
|
||||||
# 5. Clean up old deployments (keep last 5)
|
|
||||||
echo "Cleaning up obsolete deployments..."
|
echo "Cleaning up obsolete deployments..."
|
||||||
cd "${DEPLOY_BASE}"
|
cd "${DEPLOY_BASE}"
|
||||||
ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do
|
ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RESTART=0
|
||||||
|
if [[ "${1:-}" == "--restart" ]]; then
|
||||||
|
RESTART=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== QuantEngine 502 Diagnosis ==="
|
||||||
|
echo "Host: $(hostname)"
|
||||||
|
echo "Time: $(date -Is)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Service Status ==="
|
||||||
|
systemctl is-active quantengine || true
|
||||||
|
systemctl is-active nginx || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Active Deployment ==="
|
||||||
|
readlink -f /home/kjh2064/quantengine_active || true
|
||||||
|
ls -ld /home/kjh2064/quantengine_active || true
|
||||||
|
ls -1dt /home/kjh2064/deployments/quantengine_* 2>/dev/null | head -n 5 || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Version Marker ==="
|
||||||
|
cat /home/kjh2064/quantengine_active/wwwroot/version.json 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Local Port Checks ==="
|
||||||
|
ss -ltnp | grep -E ':(5000|443)\s' || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Loopback HTTP Check ==="
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/ || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Favicon Checks ==="
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/favicon.svg || true
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/favicon.png || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Public HTTP Check ==="
|
||||||
|
curl -i --max-time 15 https://quant.taxbaik.com/ || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Nginx Config Test ==="
|
||||||
|
nginx -t || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Recent QuantEngine Logs ==="
|
||||||
|
journalctl -u quantengine -n 120 --no-pager || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "$RESTART" -eq 1 ]]; then
|
||||||
|
echo "=== Restarting Services ==="
|
||||||
|
systemctl restart quantengine
|
||||||
|
systemctl reload nginx
|
||||||
|
sleep 2
|
||||||
|
echo
|
||||||
|
echo "=== Post-Restart Status ==="
|
||||||
|
systemctl is-active quantengine || true
|
||||||
|
systemctl is-active nginx || true
|
||||||
|
echo
|
||||||
|
echo "=== Post-Restart Loopback Check ==="
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/ || true
|
||||||
|
echo
|
||||||
|
echo "=== Public Endpoint Check ==="
|
||||||
|
curl -i --max-time 15 https://quant.taxbaik.com/ || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Next Step ==="
|
||||||
|
echo "If http://127.0.0.1:5000/ fails, the problem is inside quantengine."
|
||||||
|
echo "If localhost works but the public domain still fails, inspect nginx/proxy config only for quant.taxbaik.com."
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC_DB="${SRC_DB:-giteadb}"
|
||||||
|
SRC_USER="${SRC_USER:-gitea}"
|
||||||
|
SRC_PASSWORD="${SRC_PASSWORD:-}"
|
||||||
|
DST_DB="${DST_DB:-quantenginedb}"
|
||||||
|
DST_USER="${DST_USER:-quantengine_app}"
|
||||||
|
DST_PASSWORD="${DST_PASSWORD:-}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
PORT="${PORT:-5432}"
|
||||||
|
SCHEMA="${SCHEMA:-quantengine}"
|
||||||
|
|
||||||
|
if [ -z "${SRC_PASSWORD}" ] || [ -z "${DST_PASSWORD}" ]; then
|
||||||
|
echo "ERROR: SRC_PASSWORD and DST_PASSWORD must be set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
echo "[1/4] Dumping ${SCHEMA} from ${SRC_DB}..."
|
||||||
|
PGPASSWORD="${SRC_PASSWORD}" pg_dump -h "${HOST}" -p "${PORT}" -U "${SRC_USER}" -n "${SCHEMA}" --no-owner --no-privileges "${SRC_DB}" > "${TMP_DIR}/quantengine_schema.sql"
|
||||||
|
grep -vE '^CREATE SCHEMA ' "${TMP_DIR}/quantengine_schema.sql" > "${TMP_DIR}/quantengine_schema.filtered.sql"
|
||||||
|
|
||||||
|
echo "[2/4] Ensuring destination schema exists..."
|
||||||
|
PGPASSWORD="${DST_PASSWORD}" psql -h "${HOST}" -p "${PORT}" -U "${DST_USER}" -d "${DST_DB}" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
CREATE SCHEMA IF NOT EXISTS ${SCHEMA} AUTHORIZATION ${DST_USER};
|
||||||
|
ALTER SCHEMA ${SCHEMA} OWNER TO ${DST_USER};
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "[3/4] Restoring into ${DST_DB}..."
|
||||||
|
PGPASSWORD="${DST_PASSWORD}" psql -h "${HOST}" -p "${PORT}" -U "${DST_USER}" -d "${DST_DB}" -v ON_ERROR_STOP=1 -f "${TMP_DIR}/quantengine_schema.filtered.sql"
|
||||||
|
|
||||||
|
echo "[4/4] Verifying restore..."
|
||||||
|
PGPASSWORD="${DST_PASSWORD}" psql -h "${HOST}" -p "${PORT}" -U "${DST_USER}" -d "${DST_DB}" -Atc "SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname = '${SCHEMA}' ORDER BY tablename;"
|
||||||
|
|
||||||
|
echo "Migration completed: ${SRC_DB}.${SCHEMA} -> ${DST_DB}.${SCHEMA}"
|
||||||
@@ -6,6 +6,7 @@ set -e
|
|||||||
|
|
||||||
NGINX_CONF="/etc/nginx/sites-available/gitea-ip.conf"
|
NGINX_CONF="/etc/nginx/sites-available/gitea-ip.conf"
|
||||||
SERVICE_FILE="/etc/systemd/system/quantengine.service"
|
SERVICE_FILE="/etc/systemd/system/quantengine.service"
|
||||||
|
APP_ENV_FILE="/home/kjh2064/.config/quantengine.env"
|
||||||
|
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo "Configuring Host Infrastructure Services"
|
echo "Configuring Host Infrastructure Services"
|
||||||
@@ -61,6 +62,7 @@ User=kjh2064
|
|||||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
|
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
|
||||||
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||||
|
EnvironmentFile=-/home/kjh2064/.config/quantengine.env
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create the QuantEngine database and application role on the local PostgreSQL instance.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_NAME="${DB_NAME:-quantenginedb}"
|
||||||
|
DB_USER="${DB_USER:-quantengine_app}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-CHANGE_ME}"
|
||||||
|
DB_HOST="${DB_HOST:-127.0.0.1}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
ADMIN_DB="${ADMIN_DB:-postgres}"
|
||||||
|
SCHEMA_NAME="${SCHEMA_NAME:-quantengine}"
|
||||||
|
|
||||||
|
echo "Creating database and role for ${DB_NAME}..."
|
||||||
|
sudo -u postgres psql -h "${DB_HOST}" -p "${DB_PORT}" -d "${ADMIN_DB}" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${DB_USER}') THEN
|
||||||
|
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${DB_PASSWORD}';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}') THEN
|
||||||
|
CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
sudo -u postgres psql -h "${DB_HOST}" -p "${DB_PORT}" -d "${DB_NAME}" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME} AUTHORIZATION ${DB_USER};
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
|
||||||
|
GRANT USAGE, CREATE ON SCHEMA ${SCHEMA_NAME} TO ${DB_USER};
|
||||||
|
ALTER SCHEMA ${SCHEMA_NAME} OWNER TO ${DB_USER};
|
||||||
|
SQL
|
||||||
|
|
||||||
|
APP_ENV_FILE="${APP_ENV_FILE:-/home/kjh2064/.config/quantengine.env}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${APP_ENV_FILE}")"
|
||||||
|
cat > "${APP_ENV_FILE}" <<EOF
|
||||||
|
ConnectionStrings__DefaultConnection=Host=127.0.0.1;Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASSWORD};Search Path=${SCHEMA_NAME};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${APP_ENV_FILE}"
|
||||||
|
|
||||||
|
echo "Database setup completed: ${DB_NAME} / ${DB_USER}"
|
||||||
|
echo "Wrote app env file: ${APP_ENV_FILE}"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'quantengine_app') THEN
|
||||||
|
CREATE ROLE quantengine_app LOGIN PASSWORD 'CHANGE_ME';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'quantenginedb') THEN
|
||||||
|
CREATE DATABASE quantenginedb OWNER quantengine_app;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
\connect quantenginedb
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS quantengine AUTHORIZATION quantengine_app;
|
||||||
|
ALTER SCHEMA quantengine OWNER TO quantengine_app;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE quantenginedb TO quantengine_app;
|
||||||
|
GRANT USAGE, CREATE ON SCHEMA quantengine TO quantengine_app;
|
||||||
@@ -29,6 +29,7 @@ def _request_json(url: str, token: str, method: str = "GET", body: dict[str, Any
|
|||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"token {token}",
|
"Authorization": f"token {token}",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
}
|
}
|
||||||
if body is not None:
|
if body is not None:
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_HOST="${DB_HOST:-127.0.0.1}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_USER="${DB_USER:-quantengine_app}"
|
||||||
|
DB_NAME="${DB_NAME:-quantenginedb}"
|
||||||
|
|
||||||
|
echo "Checking database availability..."
|
||||||
|
psql "host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER}" -Atc "select current_database(), current_schema();"
|
||||||
|
echo "Checking core tables..."
|
||||||
|
psql "host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER}" -Atc "\dt quantengine.*"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_HOST="${DB_HOST:-127.0.0.1}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_USER="${DB_USER:-quantengine_app}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-}"
|
||||||
|
DB_NAME="${DB_NAME:-quantenginedb}"
|
||||||
|
SCHEMA="${SCHEMA:-quantengine}"
|
||||||
|
|
||||||
|
if [ -z "${DB_PASSWORD}" ]; then
|
||||||
|
echo "ERROR: DB_PASSWORD must be set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Row counts in ${DB_NAME}.${SCHEMA}:"
|
||||||
|
for table in workspace_account workspace_session settings account_snapshot workspace_approval_v2 workspace_lock kis_tokens kis_collection_runs kis_collection_snapshots kis_collection_errors; do
|
||||||
|
count=$(PGPASSWORD="${DB_PASSWORD}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" -d "${DB_NAME}" -Atc "SELECT COUNT(*) FROM ${SCHEMA}.${table};" 2>/dev/null || echo "MISSING")
|
||||||
|
printf '%s %s\n' "${table}" "${count}"
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user