13 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
38 changed files with 1191 additions and 943 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 성공 여부로 판단한다.
## 운영 리포트 계약
+6 -15
View File
@@ -206,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. 러너 설정
@@ -401,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 등록
+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()
{
@@ -7,25 +7,42 @@ 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 StorageKey = "quant_admin_session";
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)
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http)
{
_localStorage = localStorage;
_http = http;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var username = await _localStorage.GetAsync<string>(StorageKey);
if (!string.IsNullOrEmpty(username))
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, "Admin")
new Claim(ClaimTypes.Role, role)
}, "QuantAdminAuth");
var user = new ClaimsPrincipal(identity);
@@ -40,14 +57,30 @@ namespace QuantEngine.Web.Client.Infrastructure
return new AuthenticationState(_anonymous);
}
public async Task MarkUserAsAuthenticatedAsync(string username)
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role)
{
await _localStorage.SetAsync(StorageKey, username);
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, "Admin")
new Claim(ClaimTypes.Role, role)
}, "QuantAdminAuth");
var user = new ClaimsPrincipal(identity);
@@ -56,8 +89,45 @@ namespace QuantEngine.Web.Client.Infrastructure
public async Task MarkUserAsLoggedOutAsync()
{
await _localStorage.DeleteAsync(StorageKey);
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
@@ -2,74 +2,36 @@
@inject HttpClient Http
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@using System.Net.Http.Json
@using Microsoft.FluentUI.AspNetCore.Components
@using QuantEngine.Web.Client.Infrastructure
<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>
<AuthorizeView>
<Authorized>
<div style="margin-left: auto; display: flex; align-items: center; gap: 12px;">
<span style="font-size: 13px; color: var(--neutral-foreground-hint);">관리자 (@context.User.Identity?.Name)</span>
<FluentButton OnClick="HandleLogoutAsync" Style="color: #ff5252; background: transparent; border: 1px solid rgba(255, 82, 82, 0.2); cursor: pointer; padding: 4px 12px; border-radius: 4px;">
로그아웃
</FluentButton>
</div>
</Authorized>
</AuthorizeView>
</FluentStack>
</FluentHeader>
<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>
<!-- 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>
}
<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>
<!-- Page Content -->
<FluentStack Orientation="Orientation.Vertical" Class="flex-1" Style="overflow-y: auto; padding: 24px;">
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@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>
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
private bool navOpen = true;
@@ -89,15 +51,14 @@
}
catch
{
// Fail-safe default fallback values
}
}
private async Task HandleLogoutAsync()
{
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
await customProvider.MarkUserAsLoggedOutAsync();
NavigationManager.NavigateTo("login");
await customProvider.LogoutFromServerAsync();
NavigationManager.NavigateTo("/login");
}
private class VersionInfo
@@ -106,4 +67,3 @@
public string? Built { get; set; }
}
}
@@ -1,10 +1,4 @@
@using Microsoft.FluentUI.AspNetCore.Components
<FluentNavMenu>
<FluentNavLink Href="/" Match="NavLinkMatch.All">
Dashboard
</FluentNavLink>
<FluentNavLink Href="/operations" Match="NavLinkMatch.Prefix">
Operations
</FluentNavLink>
</FluentNavMenu>
<MudNavMenu>
<MudNavLink Href="/dashboard" Match="NavLinkMatch.All">Dashboard</MudNavLink>
<MudNavLink Href="/operations" Match="NavLinkMatch.Prefix">Operations</MudNavLink>
</MudNavMenu>
@@ -6,118 +6,88 @@
<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>
}
}
@@ -138,7 +108,6 @@ else if (DashboardState != null)
try
{
DashboardState = await ApiClient.GetCollectionStateAsync();
var runsResponse = await ApiClient.GetCollectionRunsAsync(10);
RecentRuns = runsResponse?.Runs ?? new();
}
@@ -1,89 +1,84 @@
@page "/"
@page "/dashboard"
@attribute [Authorize]
@using QuantEngine.Core.Infrastructure
@inject HttpClient Http
<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>
<MudText Typo="Typo.h4" Class="mb-2">Quant Engine</MudText>
<MudText Typo="Typo.body2" Class="mb-4">운영 진입점입니다. 로그인 후 현재 스냅샷 상태와 리포트 경로만 표시합니다.</MudText>
<!-- 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>
<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>
<!-- 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;">
<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>Excel/GAS 의존 문구는 제거 대상입니다.</li>
<li>숫자는 provenance 없으면 표시하지 않습니다.</li>
</ul>
</div>
</FluentCard>
</FluentStack>
</MudPaper>
</MudItem>
</MudGrid>
<!-- 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>
<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();
@@ -1,264 +1,55 @@
@page "/login"
@attribute [AllowAnonymous]
@layout AuthLayout
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject HttpClient Http
<PageTitle>로그인 - QuantEngine</PageTitle>
<div class="auth-container">
<div class="auth-card">
<div class="brand-section">
<img src="images/quant_engine_logo.jpg" alt="QuantEngine Logo" class="brand-logo" />
<h1 class="brand-title">QuantEngine</h1>
<p class="brand-subtitle">은퇴자산포트폴리오 투자 관리 시스템</p>
</div>
<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>
<form @onsubmit="HandleLoginAsync" class="auth-form">
<div class="form-group">
<label for="username">관리자 아이디</label>
<input type="text" id="username" class="form-control" @bind="Username" placeholder="아이디를 입력하세요" autocomplete="username" />
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" class="form-control" @bind="Password" placeholder="비밀번호를 입력하세요" autocomplete="current-password" />
</div>
<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))
{
<div class="error-message">
<svg class="error-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>@ErrorMessage</span>
</div>
<MudAlert Severity="Severity.Error">@ErrorMessage</MudAlert>
}
<button type="submit" class="btn-submit" disabled="@IsSubmitting">
@if (IsSubmitting)
{
<span class="spinner"></span>
<span>인증 중...</span>
}
else
{
<span>로그인</span>
}
</button>
</form>
</div>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true" Disabled="@IsSubmitting" OnClick="HandleLoginAsync">
@(IsSubmitting ? "인증 중..." : "로그인")
</MudButton>
</MudStack>
</MudPaper>
</MudContainer>
<style>
.auth-container {
display: flex;
align-items: center;
justify-content: center;
.login-shell {
min-height: 100vh;
width: 100vw;
background: linear-gradient(135deg, #090a15 0%, #12142d 100%);
font-family: 'Roboto', 'Inter', sans-serif;
color: #ffffff;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
overflow: hidden;
}
/* Ambient background glow */
.auth-container::before {
content: "";
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(0, 242, 254, 0.08) 0%, rgba(79, 172, 254, 0) 70%);
top: -10%;
left: -10%;
pointer-events: none;
}
.auth-container::after {
content: "";
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(79, 172, 254, 0.08) 0%, rgba(0, 242, 254, 0) 70%);
bottom: -10%;
right: -10%;
pointer-events: none;
}
.auth-card {
background: rgba(255, 255, 255, 0.02);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
padding: 48px;
width: 440px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
animation: fadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.brand-section {
text-align: center;
margin-bottom: 36px;
display: flex;
flex-direction: column;
align-items: center;
}
.brand-logo {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(0, 242, 254, 0.3);
box-shadow: 0 0 20px rgba(0, 242, 254, 0.15);
margin-bottom: 16px;
transition: transform 0.3s ease;
}
.brand-logo:hover {
transform: rotate(5deg) scale(1.05);
}
.brand-title {
font-size: 26px;
font-weight: 700;
margin: 0;
background: linear-gradient(90deg, #00f2fe 0%, #4facfe 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.5px;
}
.brand-subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin: 6px 0 0 0;
font-weight: 300;
}
.auth-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
padding-left: 2px;
}
.form-control {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 14px 16px;
color: #ffffff;
font-size: 14px;
transition: all 0.3s ease;
outline: none;
}
.form-control:focus {
border-color: rgba(0, 242, 254, 0.6);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 12px rgba(0, 242, 254, 0.15);
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.error-message {
display: flex;
align-items: center;
gap: 10px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 10px;
padding: 12px 16px;
color: #f87171;
font-size: 13px;
}
.error-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.btn-submit {
background: linear-gradient(90deg, #00f2fe 0%, #4facfe 100%);
border: none;
border-radius: 10px;
padding: 14px;
color: #0b0c15;
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.2);
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%);
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 242, 254, 0.35);
}
.btn-submit:active:not(:disabled) {
transform: translateY(0);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(11, 12, 21, 0.25);
border-top-color: #0b0c15;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@keyframes spin {
to { transform: rotate(360deg); }
}
@@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
.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>
@@ -267,6 +58,27 @@
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()
{
@@ -282,14 +94,18 @@
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(Username);
// Redirect back to home dashboard
NavigationManager.NavigateTo("");
await customProvider.MarkUserAsAuthenticatedAsync(auth.Username ?? Username, auth.AccessToken, auth.Role ?? "Admin", RememberUsername);
NavigationManager.NavigateTo("/dashboard");
}
else
{
@@ -3,87 +3,82 @@
@using QuantEngine.Core.Infrastructure
@inject HttpClient Http
<PageTitle>Quant Engine - Operations</PageTitle>
<PageTitle>QuantEngine - 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>
<MudText Typo="Typo.h4" Class="mb-2">Operational Report</MudText>
<MudText Typo="Typo.body2" Class="mb-4">Temp/operational_report.json만 읽는 운영 고정 화면입니다.</MudText>
<!-- 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>
<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>
<!-- Highlight Sections Grid -->
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
<MudGrid Spacing="2" Class="mb-4">
@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>
<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>
}
</FluentStack>
</MudGrid>
<!-- 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>
<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>
<!-- 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>
<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();
@@ -106,7 +101,7 @@
SchemaVersion = report.SchemaVersion;
SourceJson = report.SourceJson;
GeneratedAt = report.GeneratedAt;
Sections.Clear();
Sections.AddRange(report.Sections);
@@ -1,14 +1,10 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using QuantEngine.Web.Client.Services;
using QuantEngine.Web.Client.Infrastructure;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Register Fluent UI
builder.Services.AddFluentUIComponents();
// Register LocalStorage for cross-platform session persistence
builder.Services.AddScoped<LocalStorageService>();
@@ -16,8 +16,7 @@
<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="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" />
</ItemGroup>
</Project>
@@ -6,8 +6,7 @@
@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.Client
@using QuantEngine.Web.Client.Pages
@using QuantEngine.Web.Client.Layout
+11 -11
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" />
<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 @rendermode="InteractiveWebAssembly" />
<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>
@@ -6,8 +6,7 @@
@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
+203 -18
View File
@@ -8,11 +8,17 @@ 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.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()
@@ -30,17 +36,28 @@ builder.Services.AddRazorComponents()
// 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();
// Fluent UI Services
builder.Services.AddFluentUIComponents();
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>();
@@ -57,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)
@@ -77,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())
{
@@ -96,25 +117,173 @@ 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", (LoginRequest request, IConfiguration config) =>
app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository workspaceRepo) =>
{
var expectedUser = config["AdminSettings:Username"] ?? "admin";
var expectedPass = config["AdminSettings:Password"] ?? "quant123!";
if (request.Username == expectedUser && request.Password == expectedPass)
static string? ReadString(JsonElement root, params string[] names)
{
return Results.Ok(new { success = true, username = request.Username });
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;
}
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
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) =>
{
@@ -177,9 +346,25 @@ app.MapRazorComponents<App>()
app.Run();
public class LoginRequest
internal sealed class QuantAdminAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
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;
}
}
@@ -8,8 +8,7 @@
</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>
+1 -1
View File
@@ -7,7 +7,7 @@
},
"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",
@@ -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

+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;
+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