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는 패턴 순서를 부분수열로 요구하므로 문장별로 따로 단언한다