19 Commits

Author SHA1 Message Date
kjh2064 2f60fbf655 Fix deploy loopback verification to accept login redirect
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 10s
2026-07-01 14:55:59 +09:00
kjh2064 f68fb10bac Fix deploy verification to use public domain
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 8s
Deploy to Production / Build & Deploy to Production (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
2026-07-01 14:41:51 +09:00
kjh2064 c1b7d29eb8 Fix deploy workflow yaml heredoc indentation
Deploy to Production / Build & Deploy to Production (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 11s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
2026-07-01 14:37:11 +09:00
kjh2064 ce3505cd33 Add admin password reset API
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 8s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
2026-07-01 14:30:33 +09:00
kjh2064 e97397ddbf Disable antiforgery on auth and add quantengine migration tools
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 8s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
2026-07-01 14:17:53 +09:00
kjh2064 6ed3de2749 Separate QuantEngine database deployment
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 11s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
2026-07-01 13:55:03 +09:00
kjh2064 3e7120c041 Add remember username on login
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 5s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 10s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m49s
2026-07-01 13:35:13 +09:00
kjh2064 784f4bdbfb fix(ui): make mud providers self-closing
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 9s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 3m5s
2026-07-01 13:28:24 +09:00
kjh2064 28e1a8775f feat(ui): migrate web shell to mudblazor
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 11s
Deploy to Production / Build & Deploy to Production (push) Failing after 1m55s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
2026-07-01 13:24:46 +09:00
kjh2064 fe8ff44d3f fix(ci): accept auth redirects in deploy verification
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 11s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 3m8s
2026-07-01 13:14:26 +09:00
kjh2064 d5d630a816 fix(web): set default authentication scheme
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 11s
Deploy to Production / Build & Deploy to Production (push) Failing after 3m4s
2026-07-01 13:09:59 +09:00
kjh2064 60022ed214 chore(ci): consolidate production deploy workflow
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 8s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m48s
2026-07-01 13:07:02 +09:00
kjh2064 90bbb1860d feat(web): add auth and fix deployment checks
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 9s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Snapshot Admin Deployment / build-and-deploy (push) Failing after 2m30s
Deploy to Production / Build & Deploy to Production (push) Failing after 3m49s
2026-07-01 13:02:10 +09:00
kjh2064 3e4d545e01 Merge branch 'chore/gitignore-dotnet-build-artifacts'
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 14s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 7s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Snapshot Admin Deployment / build-and-deploy (push) Failing after 3m16s
Deploy to Production / Build & Deploy to Production (push) Failing after 4m34s
2026-07-01 11:32:24 +09:00
kjh2064 e4290ef3c6 feat(ui): 모든 중요 페이지에 [Authorize] 인가 가드 적용하여 보안 강화 2026-07-01 11:27:50 +09:00
kjh2064 4de9339163 feat(ui): Blazor WebAssembly 마이그레이션 및 API-First 로그인 구현 2026-07-01 11:22:09 +09:00
kjh2064 bdb9262f4e feat(auth): QuantEngine 관리자 로그인 페이지 및 세션 인증 체계 구현 (WBS-AUTH) 2026-07-01 11:12:20 +09:00
kjh2064 8bd678c7c7 Merge pull request 'docs(cloud): 클라우드 서버 도메인 가상 호스트 및 HTTPS 설정 지침 반영' (#13) from chore/gitignore-dotnet-build-artifacts into main
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 10s
Snapshot Admin Deployment / build-and-deploy (push) Failing after 1m1s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m33s
Reviewed-on: #13
2026-07-01 10:46:06 +09:00
kjh2064 24c1cce542 docs(cloud): 클라우드 서버 도메인 기반 가상 호스트(HTTPS) 설정 지침 최신화 및 Nginx 백업 추가 (WBS-CLOUD-5)
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 7s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
2026-07-01 10:39:11 +09:00
54 changed files with 1780 additions and 858 deletions
+177 -160
View File
@@ -2,193 +2,210 @@ name: Deploy to Production
on:
push:
branches: [ main ]
branches:
- main
workflow_dispatch:
concurrency:
group: deploy-prod-main
cancel-in-progress: true
env:
DEPLOY_HOST: 172.17.0.1
DEPLOY_HOST: 178.104.200.7
DEPLOY_USER: kjh2064
DEPLOY_PATH: /home/kjh2064/quantengine_active
SERVICE_NAME: quantengine
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"
jobs:
build-and-deploy:
name: Build & Deploy to Production
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python Dependencies
run: pip install pyyaml openpyxl requests
- name: Install Python Dependencies
run: pip install pyyaml openpyxl requests
- name: "[GATE] Run Core Validations"
run: |
echo "🔐 Running critical CI validations..."
python3 tools/validate_no_direct_api_trading_v1.py || exit 1
python3 tools/validate_specs.py || exit 1
echo "✅ All critical validations passed"
- name: "[GATE] Run Core Validations"
run: |
echo "🔐 Running critical CI validations..."
python3 tools/validate_no_direct_api_trading_v1.py || exit 1
python3 tools/validate_specs.py || exit 1
echo "✅ All critical validations passed"
- name: Ensure Temp Directory and Mock Packet
run: |
mkdir -p Temp
# 빈 패킷 객체를 생성하여 dotnet test/run 시 IO Exception 방어
if [ ! -f Temp/final_decision_packet_active.json ]; then
echo '{"active_decision": "PASS", "details": "CI dummy packet"}' > Temp/final_decision_packet_active.json
fi
- name: Ensure Temp Directory and Mock Packet
run: |
mkdir -p Temp
if [ ! -f Temp/final_decision_packet_active.json ]; then
echo '{"active_decision": "PASS", "details": "CI dummy packet"}' > Temp/final_decision_packet_active.json
fi
- name: Restore Dependencies
run: dotnet restore src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj
- name: Restore Dependencies
run: dotnet restore src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj
- name: Build Release
run: |
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
-c Release \
--no-restore \
-p:Version=1.0.${{ github.run_number }}
- name: Build Release
run: |
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
-c Release \
--no-restore
- name: Run Unit Tests
run: |
if [ -d tests/unit ]; then
dotnet test tests/unit \
- name: Run Unit Tests
run: |
dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj \
-c Release \
--no-build
- name: Publish Release Package
run: |
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
-c Release \
--no-build \
|| echo "⚠️ Some tests failed (non-blocking for web service)"
fi
-o ./publish
- name: Publish Release Package
run: |
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
-c Release \
--no-build \
-o ./publish-output
- 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: 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-output/wwwroot
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 "✓ Generated version info: 1.0.${{ github.run_number }}-$COMMIT_HASH @ $BUILD_TIME"
- 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
- 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
if [ "\$i" -eq "\$ATTEMPTS" ]; then
echo "=== FATAL: 서비스가 헬스체크 응답을 하지 않음 ===" >&2
systemctl is-active ${{ env.SERVICE_NAME }} >&2 || true
journalctl -u ${{ env.SERVICE_NAME }} --no-pager -n 50 >&2
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- 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
fi
echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)"
sleep 3
done
REMOTE
echo "✓ 배포 완료: quantengine_${TIMESTAMP} @ $DEPLOY_HOST"
send_telegram "✅ <b>QuantEngine 배포 완료</b>
커밋: <code>${COMMIT}</code>
시간: <code>${TIMESTAMP}</code>
대상: <code>${DEPLOY_HOST}</code>"
echo "=== Verifying Favicon Assets ==="
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")
echo "/favicon.svg -> ${favicon_svg_code}"
echo "/favicon.png -> ${favicon_png_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>"
-131
View File
@@ -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>"
+2
View File
@@ -110,6 +110,8 @@
- D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다.
- 매주 주말 리밸런싱(rebalance_required=true) 및 매월 1일/11일/21일 중간점검(mid_check_required=true) 운영 cadence를 준수한다.
- 커밋, 푸쉬, 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. 보고 규칙
- 모든 숫자에는 반드시 provenance(출처)를 남기며, 출처가 유효하지 않거나 없는 숫자는 보고서 표기를 전면 배제(DATA_MISSING 처리)한다.
+1 -1
View File
@@ -144,7 +144,7 @@ npm run prepare-upload-zip
## CI / 배포 분리
- `.gitea/workflows/ci.yml`은 검증 전용이다.
- `.gitea/workflows/snapshot_admin_deploy.yml`은 실배포 전용이다.
- `.gitea/workflows/deploy-prod.yml`은 실배포 전용이다.
- 공개 URL `http://178.104.200.7/quant/` 갱신은 deploy workflow 성공 여부로 판단한다.
## 운영 리포트 계약
+79
View File
@@ -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
View File
@@ -16,8 +16,8 @@
| 3.2 | [Python 가상 환경](#32-python-가상-환경) | `~/.venv`, `python3` 사용 규칙 |
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 443, 2222, 3000, 5000, 5001, 5432 |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 가상 호스트 기반 분기 |
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
@@ -117,55 +117,30 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
| 포트 | 서비스 | 바인드 | 비고 |
|---|---|---|---|
| **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 접속 |
| **3000** | Gitea Web | `127.0.0.1` | Nginx 프록시 경유 |
| **5000** | QuantEngine Blazor | `127.0.0.1` | Nginx `/quant/` 경유 |
| **3000** | Gitea Web | `127.0.0.1` | Nginx 프록시 경유 (`gitea.taxbaik.com`) |
| **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 네트워크 |
### 4.2. Nginx 리버스 프록시
```nginx
# /etc/nginx/sites-enabled/gitea-ip.conf
도메인 기반 가상 호스트(Virtual Host) 방식을 사용하여 각 도메인 요청을 내부 서비스로 연결하고, SSL(HTTPS)을 필수로 적용합니다. HTTP(80) 포트 요청은 자동으로 HTTPS(443)로 리다이렉트됩니다.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 512M;
상세 Nginx 설정 백업은 `deploy/nginx-taxbaik-domains.conf`에 위치합니다.
# QuantEngine Blazor Web App
location /quant/ {
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;
}
# 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;
}
}
```
#### 가상 호스트 설정 개요
- **TaxBaik 홈페이지** (`https://taxbaik.com`, `https://www.taxbaik.com`) ➜ `http://127.0.0.1:5001/taxbaik/`
- **Gitea (코드 저장소)** (`https://gitea.taxbaik.com`) ➜ `http://127.0.0.1:3000`
- **QuantEngine (Blazor Admin)** (`https://quant.taxbaik.com`) ➜ `http://127.0.0.1:5000/`
**라우팅 요약**:
- `http://178.104.200.7/` → Gitea Web UI
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
- `ssh://178.104.200.7:2222` → Gitea Git SSH
- `https://taxbaik.com` & `https://www.taxbaik.com` ➜ TaxBaik 홈페이지 (통합 앱)
- `https://gitea.taxbaik.com` ➜ Gitea Web UI
- `https://quant.taxbaik.com` ➜ QuantEngine Blazor Admin
- `ssh://git@gitea.taxbaik.com:2222` ➜ Gitea Git SSH
## 5. Gitea
@@ -231,8 +206,9 @@ services:
### 6.4. CI / 배포 분리
- `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다.
- `.gitea/workflows/snapshot_admin_deploy.yml`: 실배포 전용. `dotnet publish``tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
- 공개 URL `/quant/` 갱신`snapshot_admin_deploy.yml`의 성공 여부를 기준으로 판단한다.
- `.gitea/workflows/deploy-prod.yml`: 실배포 전용. `dotnet publish``tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
- 수동 배포 금지: 로컬에서 `scp`/`rsync``quantengine_active` 갱신하지 않는다. 배포는 CI가 원격에서만 수행하고, 로컬 스크립트는 `CI_DEPLOY=1` 없이 실행되면 실패해야 한다.
- 공개 URL 갱신은 `deploy-prod.yml`의 성공 여부를 기준으로 판단한다.
### 6.2. 러너 설정
@@ -335,8 +311,8 @@ ClientAliveCountMax 2
- **상태**: `ENABLED=yes` (`/etc/ufw/ufw.conf`)
- **로그 레벨**: `low`
- **외부 개방 포트**: 22 (SSH), 80 (HTTP/Nginx), 2222 (Gitea SSH)
- **내부 전용**: 3000 (Gitea Web), 5000 (QuantEngine), 5432 (PostgreSQL)
- **외부 개방 포트**: 22 (SSH), 80 (HTTP), 443 (HTTPS), 2222 (Gitea SSH)
- **내부 전용**: 3000 (Gitea Web), 5000 (QuantEngine), 5001 (TaxBaik Web), 5432 (PostgreSQL)
> 상세 규칙 확인: `sudo ufw status numbered` (TTY + sudo 비밀번호 필요)
@@ -349,8 +325,9 @@ ClientAliveCountMax 2
- Gitea Web: `127.0.0.1:3000` (로컬 전용)
- QuantEngine: `127.0.0.1:5000` (로컬 전용)
- TaxBaik Web: `127.0.0.1:5001` (로컬 전용)
- 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. 디렉토리 맵
@@ -390,7 +367,7 @@ ClientAliveCountMax 2
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
| **리버스 프록시** | Synology 내장 | Nginx 도메인 가상 호스트 및 SSL (HTTPS) 적용 (`deploy/nginx-taxbaik-domains.conf`) |
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
@@ -425,19 +402,9 @@ docker ps -a
### QuantEngine 배포
```bash
# 1. 새 배포 디렉토리 생성
DEPLOY_DIR=~/deployments/quantengine_$(date +%Y%m%d_%H%M%S)
mkdir -p "$DEPLOY_DIR"
# 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
# CI에서만 배포
# 로컬에서 scp/rsync로 quantengine_active를 갱신하지 않는다.
# 배포는 .gitea/workflows/deploy-prod.yml 실행 결과로만 반영한다.
```
### Gitea Act Runner 등록
@@ -452,14 +419,20 @@ docker run -d \
gitea/act_runner:latest
```
### SSH 접속
### SSH 접속 및 Git 원격 설정
```bash
# Windows 로컬에서
# Windows 로컬에서 서버 SSH 접속
ssh kjh2064@178.104.200.7
# Gitea Git 접속
git remote set-url origin ssh://git@178.104.200.7:2222/kjh2064/QuantEngineByItz.git
# 로컬 프로젝트의 Git Remote URL 변경 (Gitea 도메인 기반 HTTPS 적용)
# 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. 검증 하네스
@@ -514,6 +487,27 @@ ssh -T -p 2222 git@178.104.200.7 2>&1 | head -1
---
> **수집 일시**: 2026-06-26 09:55 KST
> **수집 방법**: `ssh kjh2064@178.104.200.7` 라이브 명령 실행
> **provenance**: 모든 값은 서버 실시간 명령 출력에서 추출. 임의 값 없음.
## 14. 트러블슈팅 (Troubleshooting)
### 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**: 모든 값은 서버 실시간 명령 출력 및 실제 오류 대처 조치 로그에서 추출. 임의 값 없음.
+5 -5
View File
@@ -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.
* **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**:
```sql
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;
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**:
```sql
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 CRUD permissions on tables & sequences
@@ -48,7 +48,7 @@ To ensure the principle of least privilege, we define three main database roles:
* **Permissions**:
```sql
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 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.
* `appsettings.json` must only contain placeholder configurations.
* 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**:
* Bind PostgreSQL only to local interfaces (`127.0.0.1`) or secure private network interfaces.
+2 -2
View File
@@ -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에서 상시 서비스로 운영할 수 있는지 검토 |
| **현재 상태** | **기술적으로는 가능**. 기본 루프백 보호 + 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 정리 로그를 피할 수 있다. |
| **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` |
@@ -1651,7 +1651,7 @@ WBS-10.1 (기반 결함 수정)
| 10.10.2 | Dashboard 상태 페이지 — 데이터 비의존형 요약으로 단순화 | DB 실패 시에도 200 응답 (완료) |
| 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Operations만 표시 (완료) |
| 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 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<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
@@ -6,6 +6,14 @@ namespace QuantEngine.Core.Interfaces
{
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
Task<IEnumerable<Setting>> GetSettingsAsync();
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
conn.Execute(@"
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
conn.Execute(@"
CREATE SCHEMA IF NOT EXISTS engine_history;
@@ -17,6 +17,89 @@ namespace QuantEngine.Infrastructure.Repositories
_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
public async Task<IEnumerable<Setting>> GetSettingsAsync()
{
@@ -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"
@using QuantEngine.Web.Services
@attribute [Authorize]
@using QuantEngine.Web.Client.Services
@inject ApiClient ApiClient
@inject ILogger<Collection> Logger
<PageTitle>QuantEngine - Collection</PageTitle>
<h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 600;">Data Collection</h1>
<p style="margin: 0 0 16px 0; color: var(--neutral-foreground-2); font-size: 14px;">
KIS API data collection dashboard. Monitor runs, snapshots, and error trends.
</p>
<MudText Typo="Typo.h4" Class="mb-2">Data Collection</MudText>
<MudText Typo="Typo.body2" Class="mb-4">KIS API data collection dashboard. API-first로만 동작합니다.</MudText>
<!-- Controls -->
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="8" Style="margin-bottom: 16px;">
<FluentButton Appearance="ButtonAppearance.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
@if (IsProcessing) { <span>Running...</span> } else { <span>Start Collection</span> }
</FluentButton>
<FluentButton Appearance="ButtonAppearance.Default" OnClick="@RefreshAsync" Disabled="@IsProcessing">
Refresh
</FluentButton>
</FluentStack>
<MudStack Row="true" Spacing="2" Class="mb-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
@(IsProcessing ? "Running..." : "Start Collection")
</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@RefreshAsync" Disabled="@IsProcessing">Refresh</MudButton>
</MudStack>
<!-- Loading skeleton -->
@if (IsLoading)
{
<FluentStack Orientation="Orientation.Vertical" VerticalGap="16">
<FluentSkeleton Width="100%" Height="60px" />
<FluentSkeleton Width="100%" Height="200px" />
</FluentStack>
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
}
else if (DashboardState != null)
{
<!-- Summary 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;">Last Run</p>
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@(DashboardState.LastRunStatus ?? "N/A")</h3>
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">@(DashboardState.LastFinishedAt ?? "Not finished")</p>
</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;">Total Snapshots</p>
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@DashboardState.TotalSnapshots</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;">Total Errors</p>
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@DashboardState.TotalErrors</h3>
</div>
</FluentCard>
</FluentStack>
<MudGrid Spacing="2" Class="mb-4">
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Last Run</MudText>
<MudText Typo="Typo.h6">@(DashboardState.LastRunStatus ?? "N/A")</MudText>
<MudText Typo="Typo.body2">@(DashboardState.LastFinishedAt ?? "Not finished")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Total Snapshots</MudText>
<MudText Typo="Typo.h6">@DashboardState.TotalSnapshots</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Total Errors</MudText>
<MudText Typo="Typo.h6">@DashboardState.TotalErrors</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Recent Errors -->
@if (DashboardState.RecentErrors.Count > 0)
{
<FluentCard Style="margin-bottom: 16px;">
<div style="padding: 16px;">
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Recent Errors</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead style="background: var(--neutral-subtle);">
<tr>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Source</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Kind</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Ticker</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Message</th>
</tr>
</thead>
<tbody>
@foreach (var error in DashboardState.RecentErrors)
{
<tr>
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@error.SourceName</td>
<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>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Recent Errors</MudText>
<MudTable Items="@DashboardState.RecentErrors" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Source</MudTh>
<MudTh>Kind</MudTh>
<MudTh>Ticker</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Source">@context.SourceName</MudTd>
<MudTd DataLabel="Kind">@context.ErrorKind</MudTd>
<MudTd DataLabel="Ticker">@context.Ticker</MudTd>
<MudTd DataLabel="Message">@context.ErrorMessage</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
}
<!-- Recent Runs -->
@if (RecentRuns != null && RecentRuns.Count > 0)
{
<FluentCard>
<div style="padding: 16px;">
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Recent Runs</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead style="background: var(--neutral-subtle);">
<tr>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Run ID</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Status</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Started</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Finished</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Snapshots</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--neutral-divider-rest);">Errors</th>
</tr>
</thead>
<tbody>
@foreach (var run in RecentRuns)
{
<tr>
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); font-family: monospace; font-size: 12px;">@run.RunId</td>
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest);">@run.Status</td>
<td style="padding: 8px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); font-size: 12px;">@run.StartedAt</td>
<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>
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Recent Runs</MudText>
<MudTable Items="@RecentRuns" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Run ID</MudTh>
<MudTh>Status</MudTh>
<MudTh>Started</MudTh>
<MudTh>Finished</MudTh>
<MudTh>Snapshots</MudTh>
<MudTh>Errors</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Run ID" Style="font-family: monospace; font-size: 12px;">@context.RunId</MudTd>
<MudTd DataLabel="Status">@context.Status</MudTd>
<MudTd DataLabel="Started">@context.StartedAt</MudTd>
<MudTd DataLabel="Finished">@context.FinishedAt</MudTd>
<MudTd DataLabel="Snapshots">@context.TotalSnapshots</MudTd>
<MudTd DataLabel="Errors">@context.TotalErrors</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
}
}
@@ -137,7 +108,6 @@ else if (DashboardState != null)
try
{
DashboardState = await ApiClient.GetCollectionStateAsync();
var runsResponse = await ApiClient.GetCollectionRunsAsync(10);
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");
}
}
@@ -2,19 +2,16 @@ using System.Net.Http.Json;
using System.Text.Json.Serialization;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Web.Services;
namespace QuantEngine.Web.Client.Services;
public class ApiClient
{
private readonly HttpClient _http;
private readonly ILogger<ApiClient> _logger;
private string BaseUrl { get; set; }
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
{
_http = http;
_logger = logger;
BaseUrl = "http://localhost:5001"; // Default for Blazor Server
}
// Collection API Methods
@@ -23,7 +20,7 @@ public class ApiClient
{
try
{
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>($"{BaseUrl}/api/collection/state");
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
}
catch (Exception ex)
{
@@ -36,7 +33,7 @@ public class ApiClient
{
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)
{
@@ -49,7 +46,7 @@ public class ApiClient
{
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)
{
@@ -62,7 +59,7 @@ public class ApiClient
{
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)
{
@@ -75,7 +72,7 @@ public class ApiClient
{
try
{
var response = await _http.PostAsJsonAsync($"{BaseUrl}/api/collection/run", new { });
var response = await _http.PostAsJsonAsync("api/collection/run", new { });
if (response.IsSuccessStatusCode)
{
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 Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Microsoft.FluentUI.AspNetCore.Components
@using Microsoft.FluentUI.AspNetCore.Components.Icons
@using QuantEngine.Web
@using QuantEngine.Web.Components
@using QuantEngine.Web.Components.Layout
@using QuantEngine.Web.Services
@using MudBlazor
@using QuantEngine.Web.Client
@using QuantEngine.Web.Client.Pages
@using QuantEngine.Web.Client.Layout
@using QuantEngine.Web.Client.Infrastructure
@using QuantEngine.Web.Client.Services
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
+12 -12
View File
@@ -1,29 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/quant/" />
<base href="/" />
<ResourcePreloader />
<!-- Fluent UI CSS -->
<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["QuantEngine.Web.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="alternate icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveWebAssembly" />
</head>
<body>
<FluentDesignSystemProvider>
<Routes />
<ReconnectModal />
</FluentDesignSystemProvider>
<MudThemeProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<Routes @rendermode="InteractiveWebAssembly" />
<ReconnectModal />
<!-- Fluent UI JS -->
<script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/fluent-components.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</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)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@using QuantEngine.Web.Client
@using QuantEngine.Web.Client.Pages
@using QuantEngine.Web.Client.Layout
<CascadingAuthenticationState>
<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 Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -6,8 +6,10 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Microsoft.FluentUI.AspNetCore.Components
@using Microsoft.FluentUI.AspNetCore.Components.Icons
@using MudBlazor
@using QuantEngine.Web
@using QuantEngine.Web.Components
@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 now = DateTime.UtcNow.ToString("o");
var body = await request.ReadAsAsync<CollectionRunRequest>();
var body = await request.ReadFromJsonAsync<CollectionRunRequest>();
var account = body?.Account ?? "real";
var tickers = body?.Tickers ?? new List<string> { "005930", "000660" };
+238 -12
View File
@@ -1,16 +1,24 @@
using QuantEngine.Web.Components;
using QuantEngine.Web.Services;
using QuantEngine.Infrastructure.Data;
using Microsoft.AspNetCore.Components.Authorization;
using QuantEngine.Web.Infrastructure;
using QuantEngine.Infrastructure.Repositories;
using QuantEngine.Infrastructure.Services;
using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Services;
using System.Text.Json;
using static QuantEngine.Application.Services.DataCollectionService;
using Microsoft.FluentUI.AspNetCore.Components;
using Serilog;
using QuantEngine.Web.Infrastructure;
using QuantEngine.Web.Client.Infrastructure;
using QuantEngine.Web.Client.Services;
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
Log.Logger = new LoggerConfiguration()
@@ -24,15 +32,32 @@ builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
.AddInteractiveWebAssemblyComponents();
// Fluent UI Services
builder.Services.AddFluentUIComponents();
// Authentication and Custom State Provider (Shared client components)
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
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;";
var configuredConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
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<DbMigrator>();
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
@@ -49,18 +74,25 @@ builder.Services.AddHttpClient<ApiClient>();
builder.Services.AddScoped<ApiClient>();
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)
using (var scope = app.Services.CreateScope())
{
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
try
{
migrator.Migrate();
// Ensure tables exist on startup
await tokenCache.GetCachedTokenAsync("_init_test_");
await collectionRepo.GetDashboardStateAsync();
await workspaceRepo.GetAccountsAsync();
Log.Information("Database tables initialized successfully");
}
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.
if (!app.Environment.IsDevelopment())
{
@@ -88,12 +117,186 @@ app.UseStatusCodePages(async ctx =>
app.UseHttpsRedirection();
app.UseAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets();
app.MapGet("/", () => Results.Redirect("/login"));
// Collection API Endpoints (must be before MapRazorComponents)
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) =>
{
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>()
.AddInteractiveServerRenderMode();
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(QuantEngine.Web.Client._Imports).Assembly);
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.Application\QuantEngine.Application.csproj" />
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="Client\QuantEngine.Web.Client.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="5.0.0-rc.4-26177.1" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="5.0.0-rc.4-26177.1" />
<PackageReference Include="MudBlazor" Version="8.6.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>
<PropertyGroup>
+5 -1
View File
@@ -7,6 +7,10 @@
},
"AllowedHosts": "*",
"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

+17 -1
View File
@@ -1,4 +1,4 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
@@ -15,6 +15,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Core.Tests", "Q
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Tools", "QuantEngine.Tools\QuantEngine.Tools.csproj", "{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Web.Client", "QuantEngine.Web\Client\QuantEngine.Web.Client.csproj", "{C5F2F3BD-1258-40FC-803A-EE7EEC928107}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+8 -9
View File
@@ -1,8 +1,12 @@
#!/bin/bash
# Quant Engine Shadow Copy Hot Deploy Script
# To be executed on Hz-Prod-01 Remote Server
#!/usr/bin/env bash
# Quant Engine CI-only hot deploy script
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"
ACTIVE_LINK="/home/kjh2064/quantengine_active"
@@ -15,11 +19,9 @@ echo "========================================="
echo "Starting Shadow Copy Hot Deploy [${TIMESTAMP}]"
echo "========================================="
# 1. Ensure directories exist
mkdir -p "${DEPLOY_BASE}"
mkdir -p "${TARGET_DIR}"
# 2. Extract build artifact to unique shadow directory
if [ -f "${TMP_ARCHIVE}" ]; then
echo "Extracting build artifact to ${TARGET_DIR}..."
tar -xzf "${TMP_ARCHIVE}" -C "${TARGET_DIR}"
@@ -29,15 +31,12 @@ else
exit 1
fi
# 3. Swap symbolic link atomically
echo "Swapping symbolic link dynamically..."
ln -sfn "${TARGET_DIR}" "${ACTIVE_LINK}"
# 4. Restart Systemd service (requires passwordless sudo reload or specific policy)
echo "Restarting Systemd service..."
sudo systemctl restart quantengine
# 5. Clean up old deployments (keep last 5)
echo "Cleaning up obsolete deployments..."
cd "${DEPLOY_BASE}"
ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do
+73
View File
@@ -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}"
+2
View File
@@ -6,6 +6,7 @@ set -e
NGINX_CONF="/etc/nginx/sites-available/gitea-ip.conf"
SERVICE_FILE="/etc/systemd/system/quantengine.service"
APP_ENV_FILE="/home/kjh2064/.config/quantengine.env"
echo "========================================="
echo "Configuring Host Infrastructure Services"
@@ -61,6 +62,7 @@ User=kjh2064
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
EnvironmentFile=-/home/kjh2064/.config/quantengine.env
[Install]
WantedBy=multi-user.target
+52
View File
@@ -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}"
+22
View 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;
+1
View File
@@ -29,6 +29,7 @@ def _request_json(url: str, token: str, method: str = "GET", body: dict[str, Any
headers = {
"Authorization": f"token {token}",
"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:
headers["Content-Type"] = "application/json"
+13
View File
@@ -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.*"
+21
View File
@@ -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