diff --git a/.github/workflows/dashboard-pages.yml b/.github/workflows/dashboard-pages.yml new file mode 100644 index 0000000..54801f0 --- /dev/null +++ b/.github/workflows/dashboard-pages.yml @@ -0,0 +1,33 @@ +name: Dashboard Pages + +# 대시보드 정적 페이지(assets/dashboard)를 GitHub Pages(spot.roboco.io)로 배포한다. +# data.json은 수집기 스택의 CloudFront가 계속 제공하며, 페이지는 이를 교차 출처로 fetch한다. +on: + push: + branches: [main] + paths: ['assets/dashboard/**', '.github/workflows/dashboard-pages.yml'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: assets/dashboard + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.ja.md b/README.ja.md index ee1fc27..f7f23b0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -96,7 +96,7 @@ yamlでイメージを切り替える。プレフィックスキャッシング robocoが常時運用している**GPUスポット確保可能性(プレイスメントスコア) x 価格の公開サービス**: -- **ダッシュボード**: https://d16jdvzof4zpo7.cloudfront.net — p5・g6eの主要タイプの +- **ダッシュボード**: https://spot.roboco.io — p5・g6eの主要タイプの リージョン/AZ別プレイスメントスコア推移、スポット価格、曜日×時間帯ヒートマップ、 コストパフォーマンスランキング(1時間おきに更新、90日分の履歴) - **データフィード**: https://d16jdvzof4zpo7.cloudfront.net/data.json — CORS全面許可。 diff --git a/README.ko.md b/README.ko.md index b223ef6..153c338 100644 --- a/README.ko.md +++ b/README.ko.md @@ -90,7 +90,7 @@ prefix caching이 기본이라 바이브 코딩의 반복 컨텍스트에 유리 roboco가 상시 운영하는 **GPU 스팟 확보 가능성(배치점수) × 가격 공개 서비스**: -- **대시보드**: https://d16jdvzof4zpo7.cloudfront.net — p5·g6e 주요 타입의 리전/AZ별 +- **대시보드**: https://spot.roboco.io — p5·g6e 주요 타입의 리전/AZ별 배치점수 추이, 스팟 가격, 요일×시간 히트맵, 가성비 랭킹 (1시간 주기 갱신, 90일 이력) - **데이터 피드**: https://d16jdvzof4zpo7.cloudfront.net/data.json — CORS 전면 허용. 스키마·이용법은 [docs/spot-feed.md](docs/spot-feed.md) diff --git a/README.md b/README.md index e131f44..35731f9 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ repeated-context patterns typical of vibe coding. roboco runs an always-on **GPU spot availability (placement score) x pricing public service**: -- **Dashboard**: https://d16jdvzof4zpo7.cloudfront.net — placement-score trends by +- **Dashboard**: https://spot.roboco.io — placement-score trends by region/AZ for the major p5 and g6e instance types, spot pricing, a day-of-week x hour-of-day heatmap, and a value-for-money ranking (refreshed hourly, 90-day history) - **Data feed**: https://d16jdvzof4zpo7.cloudfront.net/data.json — fully CORS-enabled. diff --git a/assets/dashboard/index.html b/assets/dashboard/index.html index ea1abe0..6122f3f 100644 --- a/assets/dashboard/index.html +++ b/assets/dashboard/index.html @@ -406,7 +406,9 @@

${t().explain2Title}

} renderChrome(); -DATA = await (await fetch('./data.json?t=' + Math.floor(Date.now()/60000), {cache:'no-store'})).json(); +// 대시보드는 GitHub Pages(spot.roboco.io), 데이터 피드는 CloudFront — 절대 URL로 교차 출처 fetch (피드는 CORS 전면 허용) +const FEED_URL = 'https://d16jdvzof4zpo7.cloudfront.net/data.json'; +DATA = await (await fetch(FEED_URL + '?t=' + Math.floor(Date.now()/60000), {cache:'no-store'})).json(); renderChrome(); // fill in DATA-dependent sub/legend now that DATA is available // ---- 확보 안정성 순위·타일 (데이터에서 자동 계산) ---- diff --git a/docs/spot-feed.md b/docs/spot-feed.md index 9331d24..2f095d9 100644 --- a/docs/spot-feed.md +++ b/docs/spot-feed.md @@ -4,8 +4,9 @@ roboco가 공개 운영하는 GPU 스팟 인텔리전스 피드입니다. 수집 1시간마다 [Spot Placement Score](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-placement-score.html)와 스팟 가격 히스토리를 수집·집계해 CloudFront로 제공합니다. -- **대시보드**: CloudFront 배포 URL의 `/` (스택 출력 `DashboardCdnUrl`) -- **데이터 피드**: 같은 URL의 `/data.json` — CORS 전면 허용, 캐시 5분 +- **대시보드**: https://spot.roboco.io — GitHub Pages로 배포(`.github/workflows/dashboard-pages.yml`). + 기존 CloudFront URL의 `/`는 이 주소로 301 리다이렉트된다. +- **데이터 피드**: CloudFront 배포 URL(스택 출력 `DashboardCdnUrl`)의 `/data.json` — CORS 전면 허용, 캐시 5분 - **커버리지**: 인스턴스 타입·리전은 `lib/spot-score-collector-stack.ts` 상단의 `INSTANCE_TYPES` / `REGIONS` 상수가 기준. 무한정 추가할 수 없음 — `GetSpotPlacementScores`는 계정당 조회 구성 수에 한도가 있다. diff --git a/lib/spot-score-collector-stack.ts b/lib/spot-score-collector-stack.ts index d97bbe1..131073e 100644 --- a/lib/spot-score-collector-stack.ts +++ b/lib/spot-score-collector-stack.ts @@ -13,6 +13,8 @@ import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'; const INSTANCE_TYPES = 'p5.48xlarge,g6e.48xlarge,g6e.24xlarge,g6e.12xlarge'; const REGIONS = 'us-east-1,us-east-2,us-west-2,ap-northeast-1,ap-northeast-2'; +// 대시보드 페이지 공개 주소 (GitHub Pages — .github/workflows/dashboard-pages.yml) +const DASHBOARD_URL = 'https://spot.roboco.io'; /** * 스팟 배치 점수 수집기 — 서빙 스택과 독립적으로 상시 가동되는 별도 스택. @@ -145,6 +147,20 @@ exports.handler = async () => { minTtl: cdk.Duration.seconds(0), maxTtl: cdk.Duration.hours(1), }); + // 대시보드 페이지는 GitHub Pages(DASHBOARD_URL)로 이전 — 기존 CloudFront URL의 페이지 경로만 + // 301로 넘기고, data.json 등 나머지(구버전 CLI feedUrl 포함)는 그대로 S3에서 제공한다 + const dashRedirectFn = new cloudfront.Function(this, 'DashboardRedirectFn', { + runtime: cloudfront.FunctionRuntime.JS_2_0, + code: cloudfront.FunctionCode.fromInline(` +function handler(event) { + var uri = event.request.uri; + if (uri === '/' || uri === '/index.html') { + return { statusCode: 301, statusDescription: 'Moved Permanently', + headers: { location: { value: '${DASHBOARD_URL}' } } }; + } + return event.request; +}`), + }); const dashCdn = new cloudfront.Distribution(this, 'DashboardCdn', { defaultBehavior: { origin: new origins.HttpOrigin(dashBucket.bucketWebsiteDomainName, { @@ -154,6 +170,9 @@ exports.handler = async () => { cachePolicy: dashCachePolicy, // data.json을 타 오리진에서 fetch로 쓸 수 있게 CORS 허용 (오픈 데이터 피드) responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS, + functionAssociations: [ + { function: dashRedirectFn, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }, + ], }, comment: 'token-forge spot dashboard + data feed', }); @@ -183,5 +202,6 @@ exports.handler = async () => { new cdk.CfnOutput(this, 'ScoresTableName', { value: table.tableName }); new cdk.CfnOutput(this, 'DashboardUrl', { value: dashBucket.bucketWebsiteUrl }); new cdk.CfnOutput(this, 'DashboardCdnUrl', { value: 'https://' + dashCdn.distributionDomainName }); + new cdk.CfnOutput(this, 'DashboardPagesUrl', { value: DASHBOARD_URL }); } } diff --git a/test/spot-score-collector-stack.test.ts b/test/spot-score-collector-stack.test.ts index 239436d..37ec630 100644 --- a/test/spot-score-collector-stack.test.ts +++ b/test/spot-score-collector-stack.test.ts @@ -101,6 +101,22 @@ test('CloudFront가 S3 website 오리진 앞에서 HTTPS 리다이렉트 + CORS }); }); +test('기존 CloudFront 대시보드 경로(/, /index.html)는 spot.roboco.io로 301, data.json은 제외', () => { + const t = makeCollectorTemplate(); + const fns = t.findResources('AWS::CloudFront::Function'); + const code: string = Object.values(fns)[0].Properties.FunctionCode; + expect(code).toContain('https://spot.roboco.io'); + expect(code).toContain('301'); + expect(code).not.toContain('data.json'); + t.hasResourceProperties('AWS::CloudFront::Distribution', { + DistributionConfig: Match.objectLike({ + DefaultCacheBehavior: Match.objectLike({ + FunctionAssociations: [Match.objectLike({ EventType: 'viewer-request' })], + }), + }), + }); +}); + test('역할에 GetSpotPlacementScores와 테이블 BatchWriteItem 권한', () => { const t = makeCollectorTemplate(); // arrayWith는 패턴 순서를 부분수열로 요구하므로 문장별로 따로 단언한다