Compare commits
13 Commits
3e4d545e01
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f60fbf655 | |||
| f68fb10bac | |||
| c1b7d29eb8 | |||
| ce3505cd33 | |||
| e97397ddbf | |||
| 6ed3de2749 | |||
| 3e7120c041 | |||
| 784f4bdbfb | |||
| 28e1a8775f | |||
| fe8ff44d3f | |||
| d5d630a816 | |||
| 60022ed214 | |||
| 90bbb1860d |
+177
-160
@@ -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>"
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
name: Snapshot Admin Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: snapshot-admin-deploy-main
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: 178.104.200.7
|
||||
DEPLOY_USER: kjh2064
|
||||
TELEGRAM_BOT_TOKEN_DEFAULT: "8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-h1ZdV0"
|
||||
TELEGRAM_CHAT_ID_DEFAULT: "-5460205872"
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Publish Blazor Web App
|
||||
run: |
|
||||
echo "[deploy] publishing .NET 10 Blazor app"
|
||||
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj -c Release -o ./publish
|
||||
|
||||
- name: Generate Build Info
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -d "+9 hours" +'%Y-%m-%d %H:%M:%S KST')
|
||||
mkdir -p ./publish/wwwroot
|
||||
printf '{\n "version": "1.0.%s-%s",\n "built": "%s"\n}\n' "${{ github.run_number }}" "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||
echo "✓ Generated version info: 1.0.${{ github.run_number }}-$COMMIT_HASH @ $BUILD_TIME"
|
||||
|
||||
|
||||
- name: Compress Artifact
|
||||
run: |
|
||||
echo "[deploy] compressing publish output"
|
||||
tar -czf quantengine.tar.gz -C ./publish .
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
if echo "${{ secrets.SSH_PRIVATE_KEY }}" | grep -q "BEGIN"; then
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
else
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_ed25519 || echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
fi
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy & Verify on Server
|
||||
run: |
|
||||
set -e
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
||||
DEPLOY_USER="${{ env.DEPLOY_USER }}"
|
||||
|
||||
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
|
||||
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
|
||||
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
|
||||
|
||||
send_telegram() {
|
||||
local text="$1"
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
--data-urlencode "text=${text}" \
|
||||
-d "parse_mode=HTML" >/dev/null || true
|
||||
}
|
||||
|
||||
notify_failure() {
|
||||
local exit_code=$?
|
||||
send_telegram "❌ <b>Snapshot Admin 배포 실패</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
시간: <code>${TIMESTAMP}</code>
|
||||
단계: snapshot_admin_deploy (Deploy Execution)"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap notify_failure ERR
|
||||
|
||||
echo "=== Deploying Snapshot Admin $COMMIT ($TIMESTAMP) ==="
|
||||
|
||||
# 1. 원격지 임시 폴더 생성 및 업로드
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /home/kjh2064/tmp"
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 quantengine.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/quantengine.tar.gz"
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh"
|
||||
|
||||
# 2. 배포 스크립트 실행
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && /home/kjh2064/tmp/deploy.sh"
|
||||
|
||||
# 3. 배포 성공 검증
|
||||
echo "=== Verifying Public Routes ==="
|
||||
root_html=$(curl -sf "http://${DEPLOY_HOST}/quant/" 2>/dev/null || echo "")
|
||||
ops_html=$(curl -sf "http://${DEPLOY_HOST}/quant/operations" 2>/dev/null || echo "")
|
||||
|
||||
root_code=$(printf '%s' "$root_html" | grep -q "Quant Engine" && echo 200 || echo 500)
|
||||
ops_code=$(printf '%s' "$ops_html" | grep -q "Operational Report" && echo 200 || echo 500)
|
||||
|
||||
echo "/quant/ -> ${root_code}"
|
||||
echo "/quant/operations -> ${ops_code}"
|
||||
|
||||
if [ "$root_code" != "200" ]; then
|
||||
echo "Deployment content check failed for /quant/" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ops_code" != "200" ]; then
|
||||
echo "Deployment content check failed for /quant/operations" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ 배포 완료: quantengine_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||
send_telegram "✅ <b>Snapshot Admin 배포 완료</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
시간: <code>${TIMESTAMP}</code>
|
||||
대상: <code>${DEPLOY_HOST}</code>"
|
||||
@@ -110,6 +110,8 @@
|
||||
- D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다.
|
||||
- 매주 주말 리밸런싱(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 처리)한다.
|
||||
|
||||
@@ -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 성공 여부로 판단한다.
|
||||
|
||||
## 운영 리포트 계약
|
||||
|
||||
@@ -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 등록
|
||||
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
+79
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RESTART=0
|
||||
if [[ "${1:-}" == "--restart" ]]; then
|
||||
RESTART=1
|
||||
fi
|
||||
|
||||
echo "=== QuantEngine 502 Diagnosis ==="
|
||||
echo "Host: $(hostname)"
|
||||
echo "Time: $(date -Is)"
|
||||
echo
|
||||
|
||||
echo "=== Service Status ==="
|
||||
systemctl is-active quantengine || true
|
||||
systemctl is-active nginx || true
|
||||
echo
|
||||
|
||||
echo "=== Active Deployment ==="
|
||||
readlink -f /home/kjh2064/quantengine_active || true
|
||||
ls -ld /home/kjh2064/quantengine_active || true
|
||||
ls -1dt /home/kjh2064/deployments/quantengine_* 2>/dev/null | head -n 5 || true
|
||||
echo
|
||||
|
||||
echo "=== Version Marker ==="
|
||||
cat /home/kjh2064/quantengine_active/wwwroot/version.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
echo "=== Local Port Checks ==="
|
||||
ss -ltnp | grep -E ':(5000|443)\s' || true
|
||||
echo
|
||||
|
||||
echo "=== Loopback HTTP Check ==="
|
||||
curl -i --max-time 10 http://127.0.0.1:5000/ || true
|
||||
echo
|
||||
|
||||
echo "=== Favicon Checks ==="
|
||||
curl -i --max-time 10 http://127.0.0.1:5000/favicon.svg || true
|
||||
curl -i --max-time 10 http://127.0.0.1:5000/favicon.png || true
|
||||
echo
|
||||
|
||||
echo "=== Public HTTP Check ==="
|
||||
curl -i --max-time 15 https://quant.taxbaik.com/ || true
|
||||
echo
|
||||
|
||||
echo "=== Nginx Config Test ==="
|
||||
nginx -t || true
|
||||
echo
|
||||
|
||||
echo "=== Recent QuantEngine Logs ==="
|
||||
journalctl -u quantengine -n 120 --no-pager || true
|
||||
echo
|
||||
|
||||
if [[ "$RESTART" -eq 1 ]]; then
|
||||
echo "=== Restarting Services ==="
|
||||
systemctl restart quantengine
|
||||
systemctl reload nginx
|
||||
sleep 2
|
||||
echo
|
||||
echo "=== Post-Restart Status ==="
|
||||
systemctl is-active quantengine || true
|
||||
systemctl is-active nginx || true
|
||||
echo
|
||||
echo "=== Post-Restart Loopback Check ==="
|
||||
curl -i --max-time 10 http://127.0.0.1:5000/ || true
|
||||
echo
|
||||
echo "=== Public Endpoint Check ==="
|
||||
curl -i --max-time 15 https://quant.taxbaik.com/ || true
|
||||
fi
|
||||
|
||||
echo "=== Next Step ==="
|
||||
echo "If http://127.0.0.1:5000/ fails, the problem is inside quantengine."
|
||||
echo "If localhost works but the public domain still fails, inspect nginx/proxy config only for quant.taxbaik.com."
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DB="${SRC_DB:-giteadb}"
|
||||
SRC_USER="${SRC_USER:-gitea}"
|
||||
SRC_PASSWORD="${SRC_PASSWORD:-}"
|
||||
DST_DB="${DST_DB:-quantenginedb}"
|
||||
DST_USER="${DST_USER:-quantengine_app}"
|
||||
DST_PASSWORD="${DST_PASSWORD:-}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-5432}"
|
||||
SCHEMA="${SCHEMA:-quantengine}"
|
||||
|
||||
if [ -z "${SRC_PASSWORD}" ] || [ -z "${DST_PASSWORD}" ]; then
|
||||
echo "ERROR: SRC_PASSWORD and DST_PASSWORD must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
echo "[1/4] Dumping ${SCHEMA} from ${SRC_DB}..."
|
||||
PGPASSWORD="${SRC_PASSWORD}" pg_dump -h "${HOST}" -p "${PORT}" -U "${SRC_USER}" -n "${SCHEMA}" --no-owner --no-privileges "${SRC_DB}" > "${TMP_DIR}/quantengine_schema.sql"
|
||||
grep -vE '^CREATE SCHEMA ' "${TMP_DIR}/quantengine_schema.sql" > "${TMP_DIR}/quantengine_schema.filtered.sql"
|
||||
|
||||
echo "[2/4] Ensuring destination schema exists..."
|
||||
PGPASSWORD="${DST_PASSWORD}" psql -h "${HOST}" -p "${PORT}" -U "${DST_USER}" -d "${DST_DB}" -v ON_ERROR_STOP=1 <<SQL
|
||||
CREATE SCHEMA IF NOT EXISTS ${SCHEMA} AUTHORIZATION ${DST_USER};
|
||||
ALTER SCHEMA ${SCHEMA} OWNER TO ${DST_USER};
|
||||
SQL
|
||||
|
||||
echo "[3/4] Restoring into ${DST_DB}..."
|
||||
PGPASSWORD="${DST_PASSWORD}" psql -h "${HOST}" -p "${PORT}" -U "${DST_USER}" -d "${DST_DB}" -v ON_ERROR_STOP=1 -f "${TMP_DIR}/quantengine_schema.filtered.sql"
|
||||
|
||||
echo "[4/4] Verifying restore..."
|
||||
PGPASSWORD="${DST_PASSWORD}" psql -h "${HOST}" -p "${PORT}" -U "${DST_USER}" -d "${DST_DB}" -Atc "SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname = '${SCHEMA}' ORDER BY tablename;"
|
||||
|
||||
echo "Migration completed: ${SRC_DB}.${SCHEMA} -> ${DST_DB}.${SCHEMA}"
|
||||
@@ -6,6 +6,7 @@ set -e
|
||||
|
||||
NGINX_CONF="/etc/nginx/sites-available/gitea-ip.conf"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create the QuantEngine database and application role on the local PostgreSQL instance.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_NAME="${DB_NAME:-quantenginedb}"
|
||||
DB_USER="${DB_USER:-quantengine_app}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-CHANGE_ME}"
|
||||
DB_HOST="${DB_HOST:-127.0.0.1}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
ADMIN_DB="${ADMIN_DB:-postgres}"
|
||||
SCHEMA_NAME="${SCHEMA_NAME:-quantengine}"
|
||||
|
||||
echo "Creating database and role for ${DB_NAME}..."
|
||||
sudo -u postgres psql -h "${DB_HOST}" -p "${DB_PORT}" -d "${ADMIN_DB}" -v ON_ERROR_STOP=1 <<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${DB_USER}') THEN
|
||||
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
|
||||
ELSE
|
||||
ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${DB_PASSWORD}';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}') THEN
|
||||
CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
SQL
|
||||
|
||||
sudo -u postgres psql -h "${DB_HOST}" -p "${DB_PORT}" -d "${DB_NAME}" -v ON_ERROR_STOP=1 <<SQL
|
||||
CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME} AUTHORIZATION ${DB_USER};
|
||||
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
|
||||
GRANT USAGE, CREATE ON SCHEMA ${SCHEMA_NAME} TO ${DB_USER};
|
||||
ALTER SCHEMA ${SCHEMA_NAME} OWNER TO ${DB_USER};
|
||||
SQL
|
||||
|
||||
APP_ENV_FILE="${APP_ENV_FILE:-/home/kjh2064/.config/quantengine.env}"
|
||||
|
||||
mkdir -p "$(dirname "${APP_ENV_FILE}")"
|
||||
cat > "${APP_ENV_FILE}" <<EOF
|
||||
ConnectionStrings__DefaultConnection=Host=127.0.0.1;Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASSWORD};Search Path=${SCHEMA_NAME};
|
||||
EOF
|
||||
|
||||
chmod 600 "${APP_ENV_FILE}"
|
||||
|
||||
echo "Database setup completed: ${DB_NAME} / ${DB_USER}"
|
||||
echo "Wrote app env file: ${APP_ENV_FILE}"
|
||||
@@ -0,0 +1,22 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'quantengine_app') THEN
|
||||
CREATE ROLE quantengine_app LOGIN PASSWORD 'CHANGE_ME';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'quantenginedb') THEN
|
||||
CREATE DATABASE quantenginedb OWNER quantengine_app;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
\connect quantenginedb
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS quantengine AUTHORIZATION quantengine_app;
|
||||
ALTER SCHEMA quantengine OWNER TO quantengine_app;
|
||||
GRANT ALL PRIVILEGES ON DATABASE quantenginedb TO quantengine_app;
|
||||
GRANT USAGE, CREATE ON SCHEMA quantengine TO quantengine_app;
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_HOST="${DB_HOST:-127.0.0.1}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-quantengine_app}"
|
||||
DB_NAME="${DB_NAME:-quantenginedb}"
|
||||
|
||||
echo "Checking database availability..."
|
||||
psql "host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER}" -Atc "select current_database(), current_schema();"
|
||||
echo "Checking core tables..."
|
||||
psql "host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER}" -Atc "\dt quantengine.*"
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_HOST="${DB_HOST:-127.0.0.1}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-quantengine_app}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-}"
|
||||
DB_NAME="${DB_NAME:-quantenginedb}"
|
||||
SCHEMA="${SCHEMA:-quantengine}"
|
||||
|
||||
if [ -z "${DB_PASSWORD}" ]; then
|
||||
echo "ERROR: DB_PASSWORD must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Row counts in ${DB_NAME}.${SCHEMA}:"
|
||||
for table in workspace_account workspace_session settings account_snapshot workspace_approval_v2 workspace_lock kis_tokens kis_collection_runs kis_collection_snapshots kis_collection_errors; do
|
||||
count=$(PGPASSWORD="${DB_PASSWORD}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" -d "${DB_NAME}" -Atc "SELECT COUNT(*) FROM ${SCHEMA}.${table};" 2>/dev/null || echo "MISSING")
|
||||
printf '%s %s\n' "${table}" "${count}"
|
||||
done
|
||||
Reference in New Issue
Block a user