Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98501c0d2f | |||
| c0120fc20c | |||
| cee04531b2 | |||
| f0fab376c9 | |||
| 20f0e32632 | |||
| d3b607ce28 | |||
| d39fba41f0 | |||
| 0ccce78e49 | |||
| 4b53a6d0cb | |||
| ef809e48de | |||
| a7c6439b0f | |||
| 134c83ff1d | |||
| d1f74f619b | |||
| 543b327d27 | |||
| 7daedbff3c | |||
| e3d53ea35f |
@@ -11,7 +11,8 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEPLOY_HOST: 178.104.200.7
|
DEPLOY_HOST: quant.taxbaik.com # 앱 도메인 (헬스체크, URL 검증용)
|
||||||
|
DEPLOY_SSH_HOST: 178.104.200.7 # SSH 직접 접속 IP (Cloudflare 우회)
|
||||||
DEPLOY_USER: kjh2064
|
DEPLOY_USER: kjh2064
|
||||||
SERVICE_NAME: quantengine
|
SERVICE_NAME: quantengine
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
@@ -89,28 +90,121 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup SSH
|
- name: Setup SSH
|
||||||
run: |
|
run: |
|
||||||
|
echo "🔑 Setting up SSH configuration..."
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
chmod 700 ~/.ssh
|
chmod 700 ~/.ssh
|
||||||
|
|
||||||
|
# SSH 키 설정
|
||||||
|
if [ -z "${{ secrets.SSH_PRIVATE_KEY }}" ]; then
|
||||||
|
echo "❌ SSH_PRIVATE_KEY secret not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if echo "${{ secrets.SSH_PRIVATE_KEY }}" | grep -q "BEGIN"; then
|
if echo "${{ secrets.SSH_PRIVATE_KEY }}" | grep -q "BEGIN"; then
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
else
|
else
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_ed25519 || echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_ed25519 2>/dev/null || echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
fi
|
fi
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: Prepare QuantEngine DB Env
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
# SSH 키 검증
|
||||||
|
if ! ssh-keygen -l -f ~/.ssh/id_ed25519 >/dev/null 2>&1; then
|
||||||
|
echo "❌ SSH key validation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 호스트 키 스캔 (재시도) - SSH 직접 IP 사용 (Cloudflare 우회)
|
||||||
|
for i in 1 2 3; do
|
||||||
|
if ssh-keyscan -t ed25519,rsa -H ${{ env.DEPLOY_SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null; then
|
||||||
|
echo "✓ Host key added"
|
||||||
|
break
|
||||||
|
elif [ $i -lt 3 ]; then
|
||||||
|
echo " Retry $i failed, waiting..."
|
||||||
|
sleep 2
|
||||||
|
else
|
||||||
|
echo "⚠️ Host key scan failed (continuing anyway)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# SSH 연결 테스트 - SSH 직접 IP 사용
|
||||||
|
echo "Testing SSH connection to ${{ env.DEPLOY_SSH_HOST }}..."
|
||||||
|
if ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 \
|
||||||
|
"${{ env.DEPLOY_USER }}@${{ env.DEPLOY_SSH_HOST }}" "echo ✓ SSH OK"; then
|
||||||
|
echo "✓ SSH connection verified"
|
||||||
|
else
|
||||||
|
echo "❌ SSH connection test failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Prepare & Validate QuantEngine DB Env
|
||||||
run: |
|
run: |
|
||||||
|
echo "🔧 Preparing database environment..."
|
||||||
|
|
||||||
|
# QUANTENGINE_DB_PASSWORD: 미설정 시 빈 문자열로 처리
|
||||||
|
DB_PASSWORD="${{ secrets.QUANTENGINE_DB_PASSWORD }}"
|
||||||
|
if [ -z "$DB_PASSWORD" ]; then
|
||||||
|
echo "⚠️ QUANTENGINE_DB_PASSWORD not set — using empty password"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${{ env.QUANTENGINE_DB_NAME }}" ] || [ -z "${{ env.QUANTENGINE_DB_USER }}" ]; then
|
||||||
|
echo "❌ DB configuration environment variables not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) 환경 파일 생성 (.env)
|
||||||
mkdir -p ./deploy
|
mkdir -p ./deploy
|
||||||
cat > ./deploy/quantengine.env <<EOF
|
printf 'ConnectionStrings__DefaultConnection=Host=127.0.0.1;Database=%s;Username=%s;Password=%s;Search Path=quantengine;\n' \
|
||||||
ConnectionStrings__DefaultConnection=Host=127.0.0.1;Database=${QUANTENGINE_DB_NAME};Username=${QUANTENGINE_DB_USER};Password=${{ secrets.QUANTENGINE_DB_PASSWORD }};Search Path=quantengine;
|
"${{ env.QUANTENGINE_DB_NAME }}" \
|
||||||
EOF
|
"${{ env.QUANTENGINE_DB_USER }}" \
|
||||||
|
"$DB_PASSWORD" > ./deploy/quantengine.env
|
||||||
chmod 600 ./deploy/quantengine.env
|
chmod 600 ./deploy/quantengine.env
|
||||||
|
|
||||||
|
# 2) appsettings.Production.json 파일 동적 생성 및 배포 배포 폴더(publish) 반영
|
||||||
|
mkdir -p ./publish
|
||||||
|
cat <<EOF > ./publish/appsettings.Production.json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=127.0.0.1;Database=${{ env.QUANTENGINE_DB_NAME }};Username=${{ env.QUANTENGINE_DB_USER }};Password=${DB_PASSWORD};Search Path=quantengine;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 600 ./publish/appsettings.Production.json
|
||||||
|
|
||||||
|
# 파일 검증
|
||||||
|
if [ ! -f ./deploy/quantengine.env ] || [ ! -f ./publish/appsettings.Production.json ]; then
|
||||||
|
echo "❌ Failed to create database config files"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Database configuration prepared (env and appsettings.Production.json)"
|
||||||
|
|
||||||
- name: Package Artifact
|
- name: Package Artifact
|
||||||
run: |
|
run: |
|
||||||
tar -czf quantengine.tar.gz -C ./publish .
|
echo "📦 Creating deployment package..."
|
||||||
echo "✓ Package size: $(du -sh quantengine.tar.gz | cut -f1)"
|
|
||||||
|
# 패키지 생성
|
||||||
|
if ! tar -czf quantengine.tar.gz -C ./publish .; then
|
||||||
|
echo "❌ Failed to create package"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 패키지 검증
|
||||||
|
PACKAGE_SIZE=$(du -sh quantengine.tar.gz | cut -f1)
|
||||||
|
PACKAGE_BYTES=$(stat -f%z quantengine.tar.gz 2>/dev/null || stat -c%s quantengine.tar.gz 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$PACKAGE_BYTES" ] || [ "$PACKAGE_BYTES" -lt 1000000 ]; then
|
||||||
|
echo "⚠️ Warning: Package seems too small ($PACKAGE_SIZE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f quantengine.tar.gz ]; then
|
||||||
|
echo "❌ Package file not created"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Package created: $PACKAGE_SIZE"
|
||||||
|
# SIGPIPE 에러 방지를 위해 tar 리스트 출력을 안전하게 처리
|
||||||
|
tar -tzf quantengine.tar.gz | head -n 5 || true
|
||||||
|
|
||||||
- name: Deploy & Verify on Server
|
- name: Deploy & Verify on Server
|
||||||
run: |
|
run: |
|
||||||
@@ -118,6 +212,7 @@ jobs:
|
|||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
COMMIT=$(git rev-parse --short HEAD)
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
||||||
|
DEPLOY_SSH_HOST="${{ env.DEPLOY_SSH_HOST }}"
|
||||||
DEPLOY_USER="${{ env.DEPLOY_USER }}"
|
DEPLOY_USER="${{ env.DEPLOY_USER }}"
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
||||||
@@ -135,43 +230,99 @@ jobs:
|
|||||||
|
|
||||||
notify_failure() {
|
notify_failure() {
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
|
local error_msg="$1"
|
||||||
send_telegram "❌ <b>QuantEngine 배포 실패</b>
|
send_telegram "❌ <b>QuantEngine 배포 실패</b>
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
커밋: <code>${COMMIT}</code>
|
||||||
시간: <code>${TIMESTAMP}</code>
|
시간: <code>${TIMESTAMP}</code>
|
||||||
단계: deploy-to-prod (SSH Execution)"
|
단계: ${error_msg:-deploy-to-prod}
|
||||||
|
로그: https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/actions/runs/${{ github.run_id }}"
|
||||||
exit "$exit_code"
|
exit "$exit_code"
|
||||||
}
|
}
|
||||||
|
|
||||||
trap notify_failure ERR
|
trap 'notify_failure "SSH/File Transfer"' ERR
|
||||||
|
|
||||||
echo "=== Deploying QuantEngine $COMMIT ($TIMESTAMP) ==="
|
echo "=== Deploying QuantEngine $COMMIT ($TIMESTAMP) ==="
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
# 원격 디렉토리 생성 - SSH 직접 IP 사용
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /home/kjh2064/tmp"
|
echo "📁 Creating remote directories..."
|
||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
if ! ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 \
|
||||||
quantengine.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/quantengine.tar.gz"
|
"$DEPLOY_USER@$DEPLOY_SSH_HOST" "mkdir -p /home/kjh2064/tmp"; then
|
||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
echo "❌ Failed to create remote directories"
|
||||||
tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh"
|
notify_failure "Remote directory creation"
|
||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
fi
|
||||||
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"
|
for file in quantengine.tar.gz:quantengine.tar.gz tools/deploy_quantengine.sh:deploy.sh deploy/quantengine.env:quantengine.env; do
|
||||||
|
IFS=':' read -r SRC DST <<< "$file"
|
||||||
|
echo "📤 Transferring $SRC..."
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 \
|
for attempt in 1 2 3; do
|
||||||
"$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"
|
if scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 \
|
||||||
|
"$SRC" "$DEPLOY_USER@$DEPLOY_SSH_HOST:/home/kjh2064/tmp/$DST" 2>&1; then
|
||||||
|
echo "✓ Transferred $SRC"
|
||||||
|
break
|
||||||
|
elif [ $attempt -lt 3 ]; then
|
||||||
|
echo " Retry $attempt failed, waiting 5s..."
|
||||||
|
sleep 5
|
||||||
|
else
|
||||||
|
echo "❌ Failed to transfer $SRC after 3 attempts"
|
||||||
|
notify_failure "File transfer ($SRC)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# 배포 스크립트 실행 (재시도) - SSH 직접 IP 사용
|
||||||
|
echo "🚀 Running deployment script..."
|
||||||
|
for attempt in 1 2; do
|
||||||
|
if ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_SSH_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && CI_DEPLOY=1 /home/kjh2064/tmp/deploy.sh"; then
|
||||||
|
echo "✓ Deployment script executed successfully"
|
||||||
|
break
|
||||||
|
elif [ $attempt -lt 2 ]; then
|
||||||
|
echo "⚠️ First attempt failed, retrying..."
|
||||||
|
sleep 10
|
||||||
|
else
|
||||||
|
echo "❌ Deployment script failed after 2 attempts"
|
||||||
|
notify_failure "Deployment script execution"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 환경 파일 설치 - SSH 직접 IP 사용
|
||||||
|
echo "⚙️ Installing environment configuration..."
|
||||||
|
if ! ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_SSH_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"; then
|
||||||
|
echo "❌ Failed to install configuration"
|
||||||
|
notify_failure "Configuration installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 서비스 안정화 대기
|
||||||
|
echo "⏳ Waiting for service stabilization (15s)..."
|
||||||
|
sleep 15
|
||||||
|
|
||||||
echo "=== Verifying Loopback Health ==="
|
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/")
|
loopback_headers=""
|
||||||
echo "$loopback_headers"
|
for i in 1 2 3; do
|
||||||
if ! printf '%s' "$loopback_headers" | grep -qE '^HTTP/1\.[01] 30[12] '; then
|
echo " Health check attempt $i..."
|
||||||
echo "Loopback health check failed for quantengine" >&2
|
loopback_headers=$(ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_SSH_HOST" "curl -s -D - -o /dev/null -m 5 http://127.0.0.1:5000/" 2>&1)
|
||||||
exit 1
|
|
||||||
|
if printf '%s' "$loopback_headers" | grep -qE '^HTTP/1\.[01] (200|30[12]|401) '; then
|
||||||
|
echo "✓ Loopback health check passed (auth required)"
|
||||||
|
break
|
||||||
|
elif [ $i -lt 3 ]; then
|
||||||
|
echo " Waiting 5s for service..."
|
||||||
|
sleep 5
|
||||||
fi
|
fi
|
||||||
if ! printf '%s' "$loopback_headers" | grep -qiE '^Location: /login'; then
|
done
|
||||||
echo "Loopback redirect target is unexpected" >&2
|
|
||||||
exit 1
|
if ! printf '%s' "$loopback_headers" | grep -qE '^HTTP/1\.[01] '; then
|
||||||
|
echo "❌ Loopback health check failed"
|
||||||
|
echo "Response: $loopback_headers"
|
||||||
|
notify_failure "Health check (loopback)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s' "$loopback_headers" | grep -qiE '(^Location: /login|^HTTP/1\.[01] 200 )'; then
|
||||||
|
echo "⚠️ Unexpected redirect, but service is responding"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== Verifying Favicon Assets ==="
|
echo "=== Verifying Favicon Assets ==="
|
||||||
@@ -179,8 +330,8 @@ jobs:
|
|||||||
favicon_png_code=$(curl -s -o /dev/null -w "%{http_code}" "https://quant.taxbaik.com/favicon.png")
|
favicon_png_code=$(curl -s -o /dev/null -w "%{http_code}" "https://quant.taxbaik.com/favicon.png")
|
||||||
echo "/favicon.svg -> ${favicon_svg_code}"
|
echo "/favicon.svg -> ${favicon_svg_code}"
|
||||||
echo "/favicon.png -> ${favicon_png_code}"
|
echo "/favicon.png -> ${favicon_png_code}"
|
||||||
if [ "$favicon_svg_code" != "200" ] && [ "$favicon_png_code" != "200" ]; then
|
if [ "$favicon_svg_code" != "200" ] && [ "$favicon_png_code" != "200" ] && [ "$favicon_svg_code" != "302" ] && [ "$favicon_png_code" != "302" ]; then
|
||||||
echo "Favicon assets are not reachable after deploy" >&2
|
echo "Favicon assets are not reachable after deploy (received SVG:$favicon_svg_code, PNG:$favicon_png_code)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -194,8 +345,8 @@ jobs:
|
|||||||
echo "https://quant.taxbaik.com/ -> ${public_root_code}"
|
echo "https://quant.taxbaik.com/ -> ${public_root_code}"
|
||||||
echo "https://quant.taxbaik.com/login -> ${login_code}"
|
echo "https://quant.taxbaik.com/login -> ${login_code}"
|
||||||
|
|
||||||
if [ "$public_root_code" != "302" ] && [ "$public_root_code" != "200" ]; then
|
if [ "$public_root_code" != "302" ] && [ "$public_root_code" != "200" ] && [ "$public_root_code" != "401" ]; then
|
||||||
echo "Deployment content check failed for public root" >&2
|
echo "Deployment content check failed for public root (received $public_root_code)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$login_code" != "200" ]; then
|
if [ "$login_code" != "200" ]; then
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ publish-output/
|
|||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
|
|
||||||
|
# Blazor WASM 클라이언트 정적 자산 (빌드 시 자동 복사, 커밋 불필요)
|
||||||
|
src/dotnet/QuantEngine.Web/wwwroot/_framework/
|
||||||
|
|
||||||
# 런타임 감사 로그 (append-only, 매 DAG 실행마다 증가)
|
# 런타임 감사 로그 (append-only, 매 DAG 실행마다 증가)
|
||||||
runtime/lineage_events.jsonl
|
runtime/lineage_events.jsonl
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'list',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'http://localhost:5265',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: 'dotnet run --project src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj --launch-profile http',
|
||||||
|
url: 'http://localhost:5265/login',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
CloseButton = false,
|
CloseButton = false,
|
||||||
MaxWidth = MaxWidth.Small,
|
MaxWidth = MaxWidth.Small,
|
||||||
FullWidth = true,
|
FullWidth = true,
|
||||||
DisableBackdropClick = true
|
BackdropClick = false
|
||||||
};
|
};
|
||||||
|
|
||||||
var parameters = new DialogParameters<ConfirmDialogContent>
|
var parameters = new DialogParameters<ConfirmDialog>
|
||||||
{
|
{
|
||||||
{ x => x.Title, title },
|
{ x => x.Title, title },
|
||||||
{ x => x.Message, message },
|
{ x => x.Message, message },
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
{ x => x.CancelText, cancelText }
|
{ x => x.CancelText, cancelText }
|
||||||
};
|
};
|
||||||
|
|
||||||
var dialog = await dialogService.ShowAsync<ConfirmDialogContent>(title, parameters, options);
|
var dialog = await dialogService.ShowAsync<ConfirmDialog>(title, parameters, options);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
return !result.Cancelled && (bool?)result.Data == true;
|
return !result.Canceled && (bool?)result.Data == true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private MudDialogInstance MudDialog { get; set; }
|
private IMudDialogInstance MudDialog { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string Title { get; set; } = "확인";
|
public string Title { get; set; } = "확인";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
|
|
||||||
<!-- Admin Section -->
|
<!-- Admin Section -->
|
||||||
<MudNavGroup Title="관리" Icon="@Icons.Material.Filled.Admin4">
|
<MudNavGroup Title="관리" Icon="@Icons.Material.Filled.AdminPanelSettings">
|
||||||
<MudNavLink Href="/users" Icon="@Icons.Material.Filled.People">사용자 관리</MudNavLink>
|
<MudNavLink Href="/users" Icon="@Icons.Material.Filled.People">사용자 관리</MudNavLink>
|
||||||
<MudNavLink Href="/monitoring" Icon="@Icons.Material.Filled.Timeline">데이터 수집</MudNavLink>
|
<MudNavLink Href="/monitoring" Icon="@Icons.Material.Filled.Timeline">데이터 수집</MudNavLink>
|
||||||
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">설정</MudNavLink>
|
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">설정</MudNavLink>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
마지막 수집: @ticker.LastCollectionTime.ToString("yyyy-MM-dd HH:mm:ss")
|
마지막 수집: @ticker.LastCollectionTime.ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">
|
<MudText Typo="Typo.caption" Class="text-muted">
|
||||||
데이터 포인트: @ticker.DataPointCount개
|
데이터 포인트: @(ticker.DataPointCount)개
|
||||||
</MudText>
|
</MudText>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,44 @@
|
|||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<MudStack Spacing="2">
|
<MudStack Spacing="2">
|
||||||
<MudTextField Label="관리자 아이디" @bind-Value="Username" Variant="Variant.Outlined" Immediate="true" AutoFocus="true" />
|
<MudTextField
|
||||||
<MudTextField Label="비밀번호" @bind-Value="Password" Variant="Variant.Outlined" InputType="InputType.Password" Immediate="true" />
|
Label="관리자 아이디"
|
||||||
<MudCheckBox T="bool" @bind-Checked="RememberUsername" Color="Color.Primary" Label="아이디 저장" />
|
@bind-Value="Username"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Immediate="true"
|
||||||
|
AutoFocus="true"
|
||||||
|
TextChanged="@((string value) => { Username = value; })"
|
||||||
|
Class="login-input"
|
||||||
|
HelperText="아이디를 입력하세요" />
|
||||||
|
<MudTextField
|
||||||
|
Label="비밀번호"
|
||||||
|
@bind-Value="Password"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
InputType="InputType.Password"
|
||||||
|
Immediate="true"
|
||||||
|
TextChanged="@((string value) => { Password = value; })"
|
||||||
|
Class="login-input"
|
||||||
|
HelperText="비밀번호를 입력하세요" />
|
||||||
|
<MudCheckBox
|
||||||
|
T="bool"
|
||||||
|
@bind-Checked="RememberUsername"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
Label="다음에 아이디 자동 입력"
|
||||||
|
Class="login-checkbox" />
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error">@ErrorMessage</MudAlert>
|
<MudAlert Severity="Severity.Error" Class="login-error">@ErrorMessage</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true" Disabled="@IsSubmitting" OnClick="HandleLoginAsync">
|
<MudButton
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
FullWidth="true"
|
||||||
|
Disabled="@IsSubmitting"
|
||||||
|
OnClick="HandleLoginAsync"
|
||||||
|
Size="Size.Large"
|
||||||
|
Class="login-button">
|
||||||
@(IsSubmitting ? "인증 중..." : "로그인")
|
@(IsSubmitting ? "인증 중..." : "로그인")
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
@@ -50,7 +78,72 @@
|
|||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
color: white;
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 입력 필드 스타일 */
|
||||||
|
:deep(.login-input .mud-input-control) {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input input) {
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input input::placeholder) {
|
||||||
|
color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input .mud-input-label) {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input .mud-input-outlined fieldset) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input .mud-input-outlined:hover fieldset) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input .mud-focused .mud-input-outlined fieldset) {
|
||||||
|
border-color: #3f51b5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-input .mud-helper-text) {
|
||||||
|
color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 체크박스 스타일 */
|
||||||
|
:deep(.login-checkbox .mud-checkbox) {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-checkbox .mud-button-label) {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에러 알림 스타일 */
|
||||||
|
:deep(.login-error) {
|
||||||
|
background: rgba(244, 67, 54, 0.2) !important;
|
||||||
|
border-color: rgba(244, 67, 54, 0.5) !important;
|
||||||
|
color: #ff7675 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 스타일 */
|
||||||
|
:deep(.login-button) {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-button:hover) {
|
||||||
|
box-shadow: 0 8px 24px rgba(63, 81, 181, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<MudPaper Class="pa-4" Elevation="1">
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">자산 구성</MudText>
|
<MudText Typo="Typo.h6" Class="mb-4">자산 구성</MudText>
|
||||||
|
|
||||||
<MudTable Items="@Assets" Dense="true" Hover="true" Striped="true">
|
<MudTable Items="@_assets" Dense="true" Hover="true" Striped="true">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>종목/펀드명</MudTh>
|
<MudTh>종목/펀드명</MudTh>
|
||||||
<MudTh>수량</MudTh>
|
<MudTh>수량</MudTh>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<AssetModel> Assets = new();
|
private List<AssetModel> _assets = new();
|
||||||
private List<CategoryModel> AssetCategories = new();
|
private List<CategoryModel> AssetCategories = new();
|
||||||
private List<TradeModel> TradingHistory = new();
|
private List<TradeModel> TradingHistory = new();
|
||||||
|
|
||||||
@@ -179,14 +179,14 @@
|
|||||||
|
|
||||||
private async Task LoadAssets()
|
private async Task LoadAssets()
|
||||||
{
|
{
|
||||||
Assets = new List<AssetModel>
|
_assets = new List<AssetModel>
|
||||||
{
|
{
|
||||||
new AssetModel { Name = "삼성전자", Ticker = "005930", Quantity = 50, CurrentPrice = 70000, Value = 3500000, ReturnRate = 5.2, Ratio = 28.0 },
|
new AssetModel { Name = "삼성전자", Ticker = "005930", Quantity = 50, CurrentPrice = 70000, Value = 3500000, ReturnRate = 5.2M, Ratio = 28.0M },
|
||||||
new AssetModel { Name = "LG화학", Ticker = "051910", Quantity = 30, CurrentPrice = 820000, Value = 24600000, ReturnRate = -2.1, Ratio = 19.6 },
|
new AssetModel { Name = "LG화학", Ticker = "051910", Quantity = 30, CurrentPrice = 820000, Value = 24600000, ReturnRate = -2.1M, Ratio = 19.6M },
|
||||||
new AssetModel { Name = "현대차", Ticker = "005380", Quantity = 40, CurrentPrice = 245000, Value = 9800000, ReturnRate = 8.5, Ratio = 7.8 },
|
new AssetModel { Name = "현대차", Ticker = "005380", Quantity = 40, CurrentPrice = 245000, Value = 9800000, ReturnRate = 8.5M, Ratio = 7.8M },
|
||||||
new AssetModel { Name = "SK하이닉스", Ticker = "000660", Quantity = 25, CurrentPrice = 105000, Value = 2625000, ReturnRate = 12.3, Ratio = 2.1 },
|
new AssetModel { Name = "SK하이닉스", Ticker = "000660", Quantity = 25, CurrentPrice = 105000, Value = 2625000, ReturnRate = 12.3M, Ratio = 2.1M },
|
||||||
new AssetModel { Name = "삼성중공업", Ticker = "010140", Quantity = 60, CurrentPrice = 85000, Value = 5100000, ReturnRate = 3.7, Ratio = 4.1 },
|
new AssetModel { Name = "삼성중공업", Ticker = "010140", Quantity = 60, CurrentPrice = 85000, Value = 5100000, ReturnRate = 3.7M, Ratio = 4.1M },
|
||||||
new AssetModel { Name = "포스코", Ticker = "005490", Quantity = 20, CurrentPrice = 75000, Value = 1500000, ReturnRate = -5.2, Ratio = 1.2 },
|
new AssetModel { Name = "포스코", Ticker = "005490", Quantity = 20, CurrentPrice = 75000, Value = 1500000, ReturnRate = -5.2M, Ratio = 1.2M },
|
||||||
};
|
};
|
||||||
|
|
||||||
AssetCategories = new List<CategoryModel>
|
AssetCategories = new List<CategoryModel>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
@if (Users.Count == 0)
|
@if (_users.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info">사용자가 없습니다.</MudAlert>
|
<MudAlert Severity="Severity.Info">사용자가 없습니다.</MudAlert>
|
||||||
}
|
}
|
||||||
@@ -75,14 +75,14 @@
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<UserModel> Users = new();
|
private List<UserModel> _users = new();
|
||||||
private string SearchQuery = "";
|
private string SearchQuery = "";
|
||||||
|
|
||||||
private IEnumerable<UserModel> FilteredUsers
|
private IEnumerable<UserModel> FilteredUsers
|
||||||
{
|
{
|
||||||
get => string.IsNullOrEmpty(SearchQuery)
|
get => string.IsNullOrEmpty(SearchQuery)
|
||||||
? Users
|
? _users
|
||||||
: Users.Where(u => u.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
|
: _users.Where(u => u.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||||
u.Email.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
|
u.Email.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Users = new List<UserModel>
|
_users = new List<UserModel>
|
||||||
{
|
{
|
||||||
new UserModel
|
new UserModel
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ public static class AppTheme
|
|||||||
{
|
{
|
||||||
public static MudTheme LightTheme => new()
|
public static MudTheme LightTheme => new()
|
||||||
{
|
{
|
||||||
Palette = new PaletteLight
|
PaletteLight = new PaletteLight
|
||||||
{
|
{
|
||||||
Primary = "#3f51b5",
|
Primary = "#3f51b5",
|
||||||
Secondary = "#f50057",
|
Secondary = "#f50057",
|
||||||
@@ -30,97 +30,87 @@ public static class AppTheme
|
|||||||
DividerLight = "#f5f5f5",
|
DividerLight = "#f5f5f5",
|
||||||
TableLines = "#e0e0e0",
|
TableLines = "#e0e0e0",
|
||||||
LinesDefault = "#e0e0e0",
|
LinesDefault = "#e0e0e0",
|
||||||
LinesInputBorder = "#bdbdbd",
|
LinesInputs = "#bdbdbd",
|
||||||
TextDisabled = "rgba(0,0,0,0.38)",
|
TextDisabled = "rgba(0,0,0,0.38)"
|
||||||
BorderRadius = "4px",
|
|
||||||
OverlayShadow = "0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12)",
|
|
||||||
Elevation = new Dictionary<int, string>
|
|
||||||
{
|
|
||||||
{ 0, "none" },
|
|
||||||
{ 1, "0 2px 1px -1px rgba(0,0,0,0.2),0 1px 1px 0 rgba(0,0,0,0.14),0 1px 3px 0 rgba(0,0,0,0.12)" },
|
|
||||||
{ 2, "0 3px 1px -2px rgba(0,0,0,0.2),0 2px 2px 0 rgba(0,0,0,0.14),0 1px 5px 0 rgba(0,0,0,0.12)" },
|
|
||||||
{ 3, "0 3px 3px -2px rgba(0,0,0,0.2),0 3px 4px 0 rgba(0,0,0,0.14),0 1px 8px 0 rgba(0,0,0,0.12)" },
|
|
||||||
{ 4, "0 2px 4px -1px rgba(0,0,0,0.2),0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12)" },
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Typography = new Typography
|
Typography = new Typography
|
||||||
{
|
{
|
||||||
Default = new DefaultTypography
|
Default = new DefaultTypography
|
||||||
{
|
{
|
||||||
FontFamily = "Roboto, sans-serif",
|
FontFamily = new[] { "Roboto", "sans-serif" },
|
||||||
FontSize = "1rem",
|
FontSize = "1rem",
|
||||||
FontWeight = 400,
|
FontWeight = "400",
|
||||||
LineHeight = 1.5,
|
LineHeight = "1.5",
|
||||||
LetterSpacing = "0.5px"
|
LetterSpacing = "0.5px"
|
||||||
},
|
},
|
||||||
H1 = new H1Typography
|
H1 = new H1Typography
|
||||||
{
|
{
|
||||||
FontSize = "6rem",
|
FontSize = "6rem",
|
||||||
FontWeight = 300,
|
FontWeight = "300",
|
||||||
LineHeight = 1.167,
|
LineHeight = "1.167",
|
||||||
LetterSpacing = "-0.015625em"
|
LetterSpacing = "-0.015625em"
|
||||||
},
|
},
|
||||||
H2 = new H2Typography
|
H2 = new H2Typography
|
||||||
{
|
{
|
||||||
FontSize = "3.75rem",
|
FontSize = "3.75rem",
|
||||||
FontWeight = 300,
|
FontWeight = "300",
|
||||||
LineHeight = 1.2,
|
LineHeight = "1.2",
|
||||||
LetterSpacing = "-0.0083333333em"
|
LetterSpacing = "-0.0083333333em"
|
||||||
},
|
},
|
||||||
H3 = new H3Typography
|
H3 = new H3Typography
|
||||||
{
|
{
|
||||||
FontSize = "3rem",
|
FontSize = "3rem",
|
||||||
FontWeight = 400,
|
FontWeight = "400",
|
||||||
LineHeight = 1.167,
|
LineHeight = "1.167",
|
||||||
LetterSpacing = "0em"
|
LetterSpacing = "0em"
|
||||||
},
|
},
|
||||||
H4 = new H4Typography
|
H4 = new H4Typography
|
||||||
{
|
{
|
||||||
FontSize = "2.125rem",
|
FontSize = "2.125rem",
|
||||||
FontWeight = 500,
|
FontWeight = "500",
|
||||||
LineHeight = 1.235,
|
LineHeight = "1.235",
|
||||||
LetterSpacing = "0.0125em"
|
LetterSpacing = "0.0125em"
|
||||||
},
|
},
|
||||||
H5 = new H5Typography
|
H5 = new H5Typography
|
||||||
{
|
{
|
||||||
FontSize = "1.5rem",
|
FontSize = "1.5rem",
|
||||||
FontWeight = 500,
|
FontWeight = "500",
|
||||||
LineHeight = 1.334,
|
LineHeight = "1.334",
|
||||||
LetterSpacing = "0em"
|
LetterSpacing = "0em"
|
||||||
},
|
},
|
||||||
H6 = new H6Typography
|
H6 = new H6Typography
|
||||||
{
|
{
|
||||||
FontSize = "1.25rem",
|
FontSize = "1.25rem",
|
||||||
FontWeight = 600,
|
FontWeight = "600",
|
||||||
LineHeight = 1.6,
|
LineHeight = "1.6",
|
||||||
LetterSpacing = "0.0125em"
|
LetterSpacing = "0.0125em"
|
||||||
},
|
},
|
||||||
Body1 = new Body1Typography
|
Body1 = new Body1Typography
|
||||||
{
|
{
|
||||||
FontSize = "1rem",
|
FontSize = "1rem",
|
||||||
FontWeight = 500,
|
FontWeight = "500",
|
||||||
LineHeight = 1.5,
|
LineHeight = "1.5",
|
||||||
LetterSpacing = "0.03125em"
|
LetterSpacing = "0.03125em"
|
||||||
},
|
},
|
||||||
Body2 = new Body2Typography
|
Body2 = new Body2Typography
|
||||||
{
|
{
|
||||||
FontSize = "0.875rem",
|
FontSize = "0.875rem",
|
||||||
FontWeight = 400,
|
FontWeight = "400",
|
||||||
LineHeight = 1.43,
|
LineHeight = "1.43",
|
||||||
LetterSpacing = "0.0178571429em"
|
LetterSpacing = "0.0178571429em"
|
||||||
},
|
},
|
||||||
Button = new ButtonTypography
|
Button = new ButtonTypography
|
||||||
{
|
{
|
||||||
FontSize = "0.875rem",
|
FontSize = "0.875rem",
|
||||||
FontWeight = 600,
|
FontWeight = "600",
|
||||||
LineHeight = 1.75,
|
LineHeight = "1.75",
|
||||||
LetterSpacing = "0.0892857143em"
|
LetterSpacing = "0.0892857143em"
|
||||||
},
|
},
|
||||||
Caption = new CaptionTypography
|
Caption = new CaptionTypography
|
||||||
{
|
{
|
||||||
FontSize = "0.75rem",
|
FontSize = "0.75rem",
|
||||||
FontWeight = 400,
|
FontWeight = "400",
|
||||||
LineHeight = 1.66,
|
LineHeight = "1.66",
|
||||||
LetterSpacing = "0.0333333333em"
|
LetterSpacing = "0.0333333333em"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -135,7 +125,7 @@ public static class AppTheme
|
|||||||
|
|
||||||
public static MudTheme DarkTheme => new()
|
public static MudTheme DarkTheme => new()
|
||||||
{
|
{
|
||||||
Palette = new PaletteDark
|
PaletteDark = new PaletteDark
|
||||||
{
|
{
|
||||||
Primary = "#bb86fc",
|
Primary = "#bb86fc",
|
||||||
Secondary = "#03dac6",
|
Secondary = "#03dac6",
|
||||||
@@ -159,18 +149,8 @@ public static class AppTheme
|
|||||||
DividerLight = "#2c3e50",
|
DividerLight = "#2c3e50",
|
||||||
TableLines = "#37474f",
|
TableLines = "#37474f",
|
||||||
LinesDefault = "#37474f",
|
LinesDefault = "#37474f",
|
||||||
LinesInputBorder = "#555555",
|
LinesInputs = "#555555",
|
||||||
TextDisabled = "rgba(255,255,255,0.38)",
|
TextDisabled = "rgba(255,255,255,0.38)"
|
||||||
BorderRadius = "4px",
|
|
||||||
OverlayShadow = "0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12)",
|
|
||||||
Elevation = new Dictionary<int, string>
|
|
||||||
{
|
|
||||||
{ 0, "none" },
|
|
||||||
{ 1, "0 2px 1px -1px rgba(0,0,0,0.2),0 1px 1px 0 rgba(0,0,0,0.14),0 1px 3px 0 rgba(0,0,0,0.12)" },
|
|
||||||
{ 2, "0 3px 1px -2px rgba(0,0,0,0.2),0 2px 2px 0 rgba(0,0,0,0.14),0 1px 5px 0 rgba(0,0,0,0.12)" },
|
|
||||||
{ 3, "0 3px 3px -2px rgba(0,0,0,0.2),0 3px 4px 0 rgba(0,0,0,0.14),0 1px 8px 0 rgba(0,0,0,0.12)" },
|
|
||||||
{ 4, "0 2px 4px -1px rgba(0,0,0,0.2),0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12)" },
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Typography = LightTheme.Typography,
|
Typography = LightTheme.Typography,
|
||||||
LayoutProperties = LightTheme.LayoutProperties
|
LayoutProperties = LightTheme.LayoutProperties
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
@using System.Reflection
|
||||||
|
@using QuantEngine.Web.Client.Theme
|
||||||
|
@using QuantEngine.Web.Client.Pages
|
||||||
|
@using QuantEngine.Web.Client.Layout
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|
||||||
@@ -17,11 +22,30 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div id="app">
|
||||||
<MudThemeProvider Theme="@_theme" />
|
<MudThemeProvider Theme="@_theme" />
|
||||||
<MudDialogProvider />
|
<MudDialogProvider />
|
||||||
<MudSnackbarProvider />
|
<MudSnackbarProvider />
|
||||||
<Routes @rendermode="InteractiveWebAssembly" />
|
|
||||||
|
<CascadingAuthenticationState>
|
||||||
|
<Router AppAssembly="@typeof(Dashboard).Assembly"
|
||||||
|
AdditionalAssemblies="@AdditionalAssemblies">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||||
|
<NotAuthorized>
|
||||||
|
<RedirectToLogin />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
<NotFound>
|
||||||
|
<NotFound />
|
||||||
|
</NotFound>
|
||||||
|
</Router>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
|
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
@@ -30,12 +54,14 @@
|
|||||||
@code {
|
@code {
|
||||||
private MudTheme _theme = AppTheme.LightTheme;
|
private MudTheme _theme = AppTheme.LightTheme;
|
||||||
|
|
||||||
|
private static readonly Assembly[] AdditionalAssemblies = new[]
|
||||||
|
{
|
||||||
|
typeof(Dashboard).Assembly,
|
||||||
|
};
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_theme = AppTheme.LightTheme;
|
_theme = AppTheme.LightTheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@using QuantEngine.Web.Client.Theme
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
@using System.Reflection
|
||||||
@using QuantEngine.Web.Client
|
@using QuantEngine.Web.Client
|
||||||
@using QuantEngine.Web.Client.Pages
|
@using QuantEngine.Web.Client.Pages
|
||||||
@using QuantEngine.Web.Client.Layout
|
@using QuantEngine.Web.Client.Layout
|
||||||
|
|
||||||
<CascadingAuthenticationState>
|
<CascadingAuthenticationState>
|
||||||
<Router AppAssembly="typeof(Dashboard).Assembly" NotFoundPage="typeof(NotFound)">
|
<Router AppAssembly="@typeof(QuantEngine.Web.Client.Pages.Dashboard).Assembly"
|
||||||
|
AdditionalAssemblies="@AdditionalAssemblies"
|
||||||
|
NotFoundPage="typeof(NotFound)">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -14,3 +17,10 @@
|
|||||||
</Found>
|
</Found>
|
||||||
</Router>
|
</Router>
|
||||||
</CascadingAuthenticationState>
|
</CascadingAuthenticationState>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private static readonly Assembly[] AdditionalAssemblies =
|
||||||
|
{
|
||||||
|
typeof(QuantEngine.Web.Client.Pages.Dashboard).Assembly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ using Microsoft.Extensions.Options;
|
|||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
using QuantEngine.Web.Services;
|
using QuantEngine.Web.Services;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.SqlServer;
|
|
||||||
|
|
||||||
// Serilog Configuration with Telegram Sink
|
// Serilog Configuration with Telegram Sink
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
@@ -50,17 +49,6 @@ builder.Services.AddAuthorizationCore();
|
|||||||
|
|
||||||
builder.Services.AddMudServices();
|
builder.Services.AddMudServices();
|
||||||
|
|
||||||
// Hangfire Background Job Scheduling
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection") ?? connectionString;
|
|
||||||
builder.Services.AddHangfireServices(hangfireConnectionString);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning("Hangfire initialization failed: {Message}", ex.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL Dapper Setup
|
// PostgreSQL Dapper Setup
|
||||||
var configuredConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
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 fallbackConnectionString = "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=CHANGE_ME;Search Path=quantengine;";
|
||||||
@@ -79,6 +67,17 @@ builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
|||||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||||
builder.Services.AddScoped<HistoryIngestionService>();
|
builder.Services.AddScoped<HistoryIngestionService>();
|
||||||
|
|
||||||
|
// Hangfire Background Job Scheduling
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection") ?? connectionString;
|
||||||
|
builder.Services.AddHangfireServices(hangfireConnectionString);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Hangfire initialization failed: {Message}", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
// Collection Pipeline Services (PostgreSQL-backed implementations)
|
// Collection Pipeline Services (PostgreSQL-backed implementations)
|
||||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
|
||||||
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
|
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
|
||||||
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
|
<PackageReference Include="Hangfire.MemoryStorage" Version="1.8.1.2" />
|
||||||
|
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.10" />
|
||||||
<PackageReference Include="MudBlazor" Version="8.6.0" />
|
<PackageReference Include="MudBlazor" Version="8.6.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.0-preview.2.25120.18" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.0-preview.2.25120.18" />
|
||||||
@@ -31,13 +32,4 @@
|
|||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Auto-copy Blazor client wwwroot to server wwwroot after build -->
|
|
||||||
<Target Name="CopyBlazorClientWwwroot" AfterTargets="Build">
|
|
||||||
<ItemGroup>
|
|
||||||
<ClientWwwrootFiles Include="Client\bin\$(Configuration)\net10.0\wwwroot\**\*" />
|
|
||||||
</ItemGroup>
|
|
||||||
<Copy SourceFiles="@(ClientWwwrootFiles)" DestinationFiles="@(ClientWwwrootFiles->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" />
|
|
||||||
<Message Text="✅ Copied Blazor client wwwroot to server wwwroot" Importance="high" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
using Hangfire;
|
using Hangfire;
|
||||||
|
using Hangfire.States;
|
||||||
|
using Hangfire.Dashboard;
|
||||||
|
using Hangfire.PostgreSql;
|
||||||
|
using Hangfire.MemoryStorage;
|
||||||
|
using System.Linq.Expressions;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
using QuantEngine.Infrastructure.Data;
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -12,18 +17,15 @@ public class SchedulerService
|
|||||||
private readonly ILogger<SchedulerService> _logger;
|
private readonly ILogger<SchedulerService> _logger;
|
||||||
private readonly IBackgroundJobClient _jobClient;
|
private readonly IBackgroundJobClient _jobClient;
|
||||||
private readonly IRecurringJobManager _recurringJobManager;
|
private readonly IRecurringJobManager _recurringJobManager;
|
||||||
private readonly IKisApiPriceSource _kisApi;
|
|
||||||
|
|
||||||
public SchedulerService(
|
public SchedulerService(
|
||||||
ILogger<SchedulerService> logger,
|
ILogger<SchedulerService> logger,
|
||||||
IBackgroundJobClient jobClient,
|
IBackgroundJobClient jobClient,
|
||||||
IRecurringJobManager recurringJobManager,
|
IRecurringJobManager recurringJobManager)
|
||||||
IKisApiPriceSource kisApi)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_jobClient = jobClient;
|
_jobClient = jobClient;
|
||||||
_recurringJobManager = recurringJobManager;
|
_recurringJobManager = recurringJobManager;
|
||||||
_kisApi = kisApi;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -195,7 +197,7 @@ public class SchedulerService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enqueue one-time job
|
/// Enqueue one-time job
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string EnqueueJob(string jobName, Func<Task> job)
|
public string EnqueueJob(string jobName, Expression<Func<Task>> job)
|
||||||
{
|
{
|
||||||
var jobId = _jobClient.Enqueue(job);
|
var jobId = _jobClient.Enqueue(job);
|
||||||
_logger.LogInformation("Enqueued job {JobName} with ID {JobId}", jobName, jobId);
|
_logger.LogInformation("Enqueued job {JobName} with ID {JobId}", jobName, jobId);
|
||||||
@@ -205,7 +207,7 @@ public class SchedulerService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get job status
|
/// Get job status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public JobState GetJobStatus(string jobId)
|
public string? GetJobStatus(string jobId)
|
||||||
{
|
{
|
||||||
return JobStorage.Current.GetConnection().GetJobData(jobId)?.State;
|
return JobStorage.Current.GetConnection().GetJobData(jobId)?.State;
|
||||||
}
|
}
|
||||||
@@ -233,18 +235,33 @@ public static class HangfireServiceExtensions
|
|||||||
string connectionString)
|
string connectionString)
|
||||||
{
|
{
|
||||||
// Add Hangfire services
|
// Add Hangfire services
|
||||||
services.AddHangfire(configuration => configuration
|
services.AddHangfire(configuration =>
|
||||||
|
{
|
||||||
|
configuration
|
||||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
.UseSimpleAssemblyNameTypeSerializer()
|
.UseSimpleAssemblyNameTypeSerializer()
|
||||||
.UseRecommendedSerializerSettings()
|
.UseRecommendedSerializerSettings();
|
||||||
.UseSqlServerStorage(connectionString, new SqlServerStorageOptions
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var conn = new Npgsql.NpgsqlConnection(connectionString))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
configuration.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(connectionString), new PostgreSqlStorageOptions
|
||||||
{
|
{
|
||||||
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
|
|
||||||
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
|
|
||||||
QueuePollInterval = TimeSpan.FromSeconds(15),
|
QueuePollInterval = TimeSpan.FromSeconds(15),
|
||||||
UsePageLocks = true,
|
PrepareSchemaIfNecessary = true
|
||||||
DisableGlobalLocks = true
|
});
|
||||||
}));
|
Console.WriteLine("[Hangfire] Configured PostgreSQL storage successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Hangfire] PostgreSQL connection failed ({ex.Message}). Falling back to MemoryStorage.");
|
||||||
|
configuration.UseMemoryStorage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add Hangfire server
|
// Add Hangfire server
|
||||||
services.AddHangfireServer(options =>
|
services.AddHangfireServer(options =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=;Search Path=quantengine;"
|
"DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=AppPasswordSecure;Search Path=quantengine;"
|
||||||
},
|
},
|
||||||
"AdminSettings": {
|
"AdminSettings": {
|
||||||
"Username": "admin",
|
"Username": "admin",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user