T-CREATOR

GitHub Actions 実行コストを見える化:Usage API でジョブ別分析ダッシュボード

GitHub Actions 実行コストを見える化:Usage API でジョブ別分析ダッシュボード

GitHub Actions を活用していると、気づかないうちに実行時間やコストが増大していることがありますよね。特に複数のワークフローやジョブが並行して動いている場合、どこでどれだけのリソースが使われているのかを把握するのは困難です。

そこで今回は、GitHub Actions Usage API を使って実行コストを可視化し、ジョブ別の分析ダッシュボードを構築する方法をご紹介します。この記事を読めば、どのワークフローやジョブが最もコストを消費しているかを一目で把握でき、最適化のポイントが見えてくるでしょう。

背景

GitHub Actions は CI/CD パイプラインを構築する上で非常に便利なサービスですが、無料枠を超えると従量課金が発生します。特に Organization やチームで利用している場合、実行時間の管理は重要な課題となってきます。

GitHub Actions のコスト構造

GitHub Actions のコストは以下の要素で決まります。

#要素説明課金対象
1実行時間ジョブの実行にかかった時間Linux は 1 分あたり 1 単位
2OS タイプ実行環境の OSWindows は 2 倍、macOS は 10 倍
3同時実行数並列で実行されるジョブ数プランによって上限あり
4ストレージArtifacts やキャッシュの保存容量月額課金

無料枠はプランによって異なりますが、Free プランでは月 2,000 分、Pro プランでは 3,000 分が提供されます。しかし、macOS での実行は 10 倍のカウントとなるため、実質的な実行時間は大幅に減少してしまうのです。

コスト管理の課題

GitHub の Web UI では総実行時間は確認できますが、以下のような詳細な分析には対応していません。

  • ワークフロー別、ジョブ別の実行時間内訳
  • 時系列での実行傾向の把握
  • 特定期間のコスト分析
  • OS タイプ別の実行時間比較

これらの情報を取得するには、GitHub Actions Usage API を活用する必要があります。

以下の図は、GitHub Actions のコスト発生から可視化までの全体フローを示しています。

mermaidflowchart TB
  workflow["ワークフロー実行"] -->|実行時間記録| github["GitHub Actions<br/>システム"]
  github -->|データ蓄積| usage["Usage データ"]
  usage -->|API 経由| api["GitHub Usage API"]
  api -->|JSON レスポンス| app["分析アプリ"]
  app -->|集計・加工| db[("データベース")]
  db -->|可視化| dashboard["ダッシュボード"]
  dashboard -->|分析結果| user["開発者"]

この図から分かるように、ワークフロー実行データは GitHub のシステムに蓄積され、Usage API を通じて取得できます。取得したデータを分析アプリで処理し、ダッシュボードで可視化することで、初めてコストの全体像が見えてくるのです。

課題

GitHub Actions のコスト管理における主な課題は以下の 3 点です。

課題 1:データの取得が困難

GitHub の Web UI では総実行時間しか確認できず、詳細なジョブ別データを取得するには API を使う必要があります。しかし、Usage API のレスポンスは複雑で、そのまま利用するのは困難でしょう。

課題 2:データの整形と集計

API から取得したデータは JSON 形式で、ワークフロー実行ごとに分かれています。これを時系列やジョブ別に集計するには、データの整形処理が必要となります。

課題 3:継続的な監視

一度データを取得するだけでは不十分で、定期的にデータを収集し、傾向を監視する仕組みが求められます。手動での取得は現実的ではありませんね。

以下の図は、これらの課題がどのように発生するかを示しています。

mermaidflowchart LR
  ui["GitHub Web UI"] -.->|総実行時間のみ| limit1["詳細データなし"]
  api["Usage API"] -->|生データ| json["複雑な JSON"]
  json -.->|加工必要| limit2["集計困難"]
  manual["手動取得"] -.->|非効率| limit3["継続監視不可"]

  limit1 --> problem["課題:<br/>コスト分析できない"]
  limit2 --> problem
  limit3 --> problem

これらの課題を解決するには、API からのデータ取得、整形、集計、可視化を自動化する仕組みが必要です。

解決策

GitHub Actions Usage API を活用し、データ取得から可視化までを自動化するダッシュボードシステムを構築します。このシステムは以下の 3 つのコンポーネントで構成されます。

全体アーキテクチャ

mermaidflowchart TB
  subgraph collect["1. データ収集層"]
    cron["定期実行<br/>GitHub Actions"] -->|API コール| fetcher["データ取得<br/>スクリプト"]
  end

  subgraph process["2. データ処理層"]
    fetcher -->|生データ| parser["JSON パーサー"]
    parser -->|整形| aggregator["集計エンジン"]
  end

  subgraph visualize["3. 可視化層"]
    aggregator -->|集計データ| storage[("SQLite")]
    storage -->|クエリ| dashboard["Next.js<br/>ダッシュボード"]
  end

このアーキテクチャでは、データ収集、処理、可視化を独立したレイヤーとして分離することで、保守性と拡張性を確保しています。

解決策の 3 つのポイント

#ポイント内容メリット
1API 自動取得GitHub Actions で定期実行手動作業不要
2データ正規化TypeScript で型安全に処理バグ削減
3リアルタイム可視化Next.js でインタラクティブに表示即座に分析可能

それでは、各コンポーネントの実装を見ていきましょう。

具体例

実際にダッシュボードシステムを構築していきます。段階的に実装を進めていきますので、一緒に作り上げていきましょう。

ステップ 1:GitHub Token の準備

まず、Usage API にアクセスするための Personal Access Token を作成します。

必要な権限は以下の通りです。

#スコープ用途
1repoプライベートリポジトリの Actions データ取得
2workflowワークフロー実行履歴へのアクセス

GitHub の Settings > Developer settings > Personal access tokens から新しいトークンを作成し、環境変数に設定してください。

typescript// .env.local
GITHUB_TOKEN = ghp_your_token_here;
GITHUB_OWNER = your - organization - or - username;
GITHUB_REPO = your - repository - name;

ステップ 2:Usage API からデータを取得

次に、Usage API を呼び出してワークフロー実行データを取得するスクリプトを作成します。

API クライアントの型定義

まず、API レスポンスの型を定義しましょう。

typescript// types/usage.ts
export interface WorkflowRun {
  id: number;
  name: string;
  run_number: number;
  created_at: string;
  updated_at: string;
  run_started_at: string;
  status: 'completed' | 'in_progress' | 'queued';
  conclusion:
    | 'success'
    | 'failure'
    | 'cancelled'
    | 'skipped'
    | null;
  workflow_id: number;
  run_attempt: number;
}
typescript// types/usage.ts (続き)
export interface WorkflowJob {
  id: number;
  run_id: number;
  workflow_name: string;
  name: string;
  status: 'completed' | 'in_progress' | 'queued';
  conclusion:
    | 'success'
    | 'failure'
    | 'cancelled'
    | 'skipped'
    | null;
  started_at: string;
  completed_at: string;
  runner_name: string;
  runner_group_name: string;
  labels: string[];
}
typescript// types/usage.ts (続き)
export interface BillableTime {
  total_ms: number;
  UBUNTU?: number;
  MACOS?: number;
  WINDOWS?: number;
}

export interface WorkflowRunUsage {
  billable: {
    UBUNTU?: BillableTime;
    MACOS?: BillableTime;
    WINDOWS?: BillableTime;
  };
  run_duration_ms: number;
}

データ取得関数の実装

型定義ができたら、実際にデータを取得する関数を実装します。

typescript// lib/github-api.ts
import { Octokit } from '@octokit/rest';
import type {
  WorkflowRun,
  WorkflowJob,
  WorkflowRunUsage,
} from '@/types/usage';

const octokit = new Octokit({
  auth: process.env.GITHUB_TOKEN,
});

const owner = process.env.GITHUB_OWNER!;
const repo = process.env.GITHUB_REPO!;

ここでは Octokit を使用して GitHub API にアクセスします。Octokit は GitHub 公式の TypeScript クライアントで、型安全に API を呼び出せますよ。

typescript// lib/github-api.ts (続き)
export async function fetchWorkflowRuns(
  perPage: number = 100,
  page: number = 1
): Promise<WorkflowRun[]> {
  const response =
    await octokit.rest.actions.listWorkflowRunsForRepo({
      owner,
      repo,
      per_page: perPage,
      page,
    });

  return response.data.workflow_runs as WorkflowRun[];
}

この関数は、指定されたリポジトリのワークフロー実行履歴を取得します。デフォルトでは 100 件ずつ取得し、ページネーションに対応していますね。

typescript// lib/github-api.ts (続き)
export async function fetchWorkflowRunJobs(
  runId: number
): Promise<WorkflowJob[]> {
  const response =
    await octokit.rest.actions.listJobsForWorkflowRun({
      owner,
      repo,
      run_id: runId,
    });

  return response.data.jobs as WorkflowJob[];
}

各ワークフロー実行には複数のジョブが含まれます。この関数で個別のジョブ情報を取得できます。

typescript// lib/github-api.ts (続き)
export async function fetchWorkflowRunUsage(
  runId: number
): Promise<WorkflowRunUsage> {
  const response =
    await octokit.rest.actions.getWorkflowRunUsage({
      owner,
      repo,
      run_id: runId,
    });

  return response.data as WorkflowRunUsage;
}

最も重要なのがこの関数です。ワークフロー実行の Usage データを取得し、OS 別の実行時間(課金対象時間)を含んでいます。

ステップ 3:データの集計と正規化

取得した生データを、ダッシュボードで表示しやすい形式に加工します。

集計用の型定義

typescript// types/analytics.ts
export interface JobAnalytics {
  jobName: string;
  workflowName: string;
  totalRuns: number;
  successRate: number;
  averageDurationMs: number;
  totalBillableMs: number;
  osBillable: {
    ubuntu: number;
    macos: number;
    windows: number;
  };
}
typescript// types/analytics.ts (続き)
export interface DailyUsage {
  date: string;
  totalRuns: number;
  totalBillableMinutes: number;
  ubuntuMinutes: number;
  macosMinutes: number;
  windowsMinutes: number;
  estimatedCost: number;
}

データ集計ロジック

以下は、取得したデータを集計する関数です。

typescript// lib/analytics.ts
import type {
  WorkflowRun,
  WorkflowJob,
  WorkflowRunUsage,
} from '@/types/usage';
import type {
  JobAnalytics,
  DailyUsage,
} from '@/types/analytics';

export function aggregateJobAnalytics(
  runs: WorkflowRun[],
  jobs: Map<number, WorkflowJob[]>,
  usages: Map<number, WorkflowRunUsage>
): JobAnalytics[] {
  const jobMap = new Map<string, JobAnalytics>();

  // 各ワークフロー実行をループ
  for (const run of runs) {
    const runJobs = jobs.get(run.id) || [];
    const usage = usages.get(run.id);

    processRunJobs(run, runJobs, usage, jobMap);
  }

  return Array.from(jobMap.values());
}

この関数は、ワークフロー実行、ジョブ、使用状況データを受け取り、ジョブごとの分析データに集計します。Map を使用することで、同じジョブ名のデータを効率的に集約できますね。

typescript// lib/analytics.ts (続き)
function processRunJobs(
  run: WorkflowRun,
  jobs: WorkflowJob[],
  usage: WorkflowRunUsage | undefined,
  jobMap: Map<string, JobAnalytics>
): void {
  for (const job of jobs) {
    const key = `${run.name}::${job.name}`;
    const existing = jobMap.get(key);

    const duration = calculateDuration(
      job.started_at,
      job.completed_at
    );
    const isSuccess = job.conclusion === 'success';

    if (existing) {
      updateExistingJob(
        existing,
        duration,
        isSuccess,
        usage
      );
    } else {
      createNewJob(
        jobMap,
        key,
        run,
        job,
        duration,
        isSuccess,
        usage
      );
    }
  }
}

各ジョブについて、既存のデータがあれば更新し、なければ新規作成します。この処理により、同じジョブの複数回の実行を集計できるのです。

typescript// lib/analytics.ts (続き)
function calculateDuration(
  startedAt: string,
  completedAt: string
): number {
  if (!startedAt || !completedAt) return 0;

  const start = new Date(startedAt).getTime();
  const end = new Date(completedAt).getTime();

  return end - start;
}

ジョブの実行時間を計算します。ISO 8601 形式の文字列をパースして、ミリ秒単位で差分を取得していますね。

typescript// lib/analytics.ts (続き)
function updateExistingJob(
  job: JobAnalytics,
  duration: number,
  isSuccess: boolean,
  usage: WorkflowRunUsage | undefined
): void {
  job.totalRuns++;
  job.averageDurationMs =
    (job.averageDurationMs * (job.totalRuns - 1) +
      duration) /
    job.totalRuns;

  if (isSuccess) {
    job.successRate =
      (job.successRate * (job.totalRuns - 1) + 1) /
      job.totalRuns;
  } else {
    job.successRate =
      (job.successRate * (job.totalRuns - 1)) /
      job.totalRuns;
  }

  addBillableTime(job, usage);
}

既存のジョブデータを更新します。平均実行時間と成功率を再計算し、課金対象時間を加算しています。

typescript// lib/analytics.ts (続き)
function createNewJob(
  jobMap: Map<string, JobAnalytics>,
  key: string,
  run: WorkflowRun,
  job: WorkflowJob,
  duration: number,
  isSuccess: boolean,
  usage: WorkflowRunUsage | undefined
): void {
  const analytics: JobAnalytics = {
    jobName: job.name,
    workflowName: run.name,
    totalRuns: 1,
    successRate: isSuccess ? 1 : 0,
    averageDurationMs: duration,
    totalBillableMs: 0,
    osBillable: {
      ubuntu: 0,
      macos: 0,
      windows: 0,
    },
  };

  addBillableTime(analytics, usage);
  jobMap.set(key, analytics);
}

新しいジョブのデータを作成します。初期値を設定し、課金対象時間を計算して Map に追加しています。

typescript// lib/analytics.ts (続き)
function addBillableTime(
  job: JobAnalytics,
  usage: WorkflowRunUsage | undefined
): void {
  if (!usage) return;

  const billable = usage.billable;

  if (billable.UBUNTU) {
    job.osBillable.ubuntu += billable.UBUNTU.total_ms;
    job.totalBillableMs += billable.UBUNTU.total_ms;
  }

  if (billable.MACOS) {
    job.osBillable.macos += billable.MACOS.total_ms;
    job.totalBillableMs += billable.MACOS.total_ms;
  }

  if (billable.WINDOWS) {
    job.osBillable.windows += billable.WINDOWS.total_ms;
    job.totalBillableMs += billable.WINDOWS.total_ms;
  }
}

課金対象時間を OS 別に加算します。GitHub Actions では OS によって課金倍率が異なるため、この情報は非常に重要ですね。

日別使用状況の集計

次は、日別の使用状況を集計する関数です。

typescript// lib/analytics.ts (続き)
export function aggregateDailyUsage(
  runs: WorkflowRun[],
  usages: Map<number, WorkflowRunUsage>
): DailyUsage[] {
  const dailyMap = new Map<string, DailyUsage>();

  for (const run of runs) {
    const date = run.created_at.split('T')[0];
    const usage = usages.get(run.id);

    if (!dailyMap.has(date)) {
      dailyMap.set(date, createDailyUsage(date));
    }

    updateDailyUsage(dailyMap.get(date)!, usage);
  }

  return Array.from(dailyMap.values()).sort((a, b) =>
    a.date.localeCompare(b.date)
  );
}

この関数は日付をキーにしてデータを集約し、時系列で並べ替えます。これにより、日々のコスト推移を追跡できますよ。

typescript// lib/analytics.ts (続き)
function createDailyUsage(date: string): DailyUsage {
  return {
    date,
    totalRuns: 0,
    totalBillableMinutes: 0,
    ubuntuMinutes: 0,
    macosMinutes: 0,
    windowsMinutes: 0,
    estimatedCost: 0,
  };
}
typescript// lib/analytics.ts (続き)
function updateDailyUsage(
  daily: DailyUsage,
  usage: WorkflowRunUsage | undefined
): void {
  daily.totalRuns++;

  if (!usage) return;

  const billable = usage.billable;

  if (billable.UBUNTU) {
    const minutes = billable.UBUNTU.total_ms / 1000 / 60;
    daily.ubuntuMinutes += minutes;
    daily.totalBillableMinutes += minutes;
    daily.estimatedCost += minutes * 0.008; // $0.008 per minute
  }

  if (billable.MACOS) {
    const minutes = billable.MACOS.total_ms / 1000 / 60;
    daily.macosMinutes += minutes;
    daily.totalBillableMinutes += minutes;
    daily.estimatedCost += minutes * 0.08; // $0.08 per minute (10x)
  }

  if (billable.WINDOWS) {
    const minutes = billable.WINDOWS.total_ms / 1000 / 60;
    daily.windowsMinutes += minutes;
    daily.totalBillableMinutes += minutes;
    daily.estimatedCost += minutes * 0.016; // $0.016 per minute (2x)
  }
}

課金対象時間を分単位に変換し、OS ごとの料金を計算します。macOS は Ubuntu の 10 倍、Windows は 2 倍の料金であることが分かりますね。

ステップ 4:データ収集の自動化

定期的にデータを収集するために、GitHub Actions ワークフローを作成します。

yaml# .github/workflows/collect-usage.yml
name: Collect Usage Data

on:
  schedule:
    # 毎日午前 9 時(JST)に実行
    - cron: '0 0 * * *'
  workflow_dispatch: # 手動実行も可能

jobs:
  collect:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

このワークフローは毎日自動実行され、手動でもトリガーできます。cron 式で実行タイミングを制御していますね。

yaml# .github/workflows/collect-usage.yml (続き)
- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'yarn'

- name: Install dependencies
  run: yarn install --frozen-lockfile

Node.js 環境をセットアップし、依存パッケージをインストールします。キャッシュを有効にすることで、実行時間を短縮できますよ。

yaml# .github/workflows/collect-usage.yml (続き)
- name: Collect usage data
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    GITHUB_OWNER: ${{ github.repository_owner }}
    GITHUB_REPO: ${{ github.event.repository.name }}
  run: yarn collect-usage

- name: Commit and push
  run: |
    git config user.name "github-actions[bot]"
    git config user.email "github-actions[bot]@users.noreply.github.com"
    git add data/
    git commit -m "chore: update usage data [skip ci]" || exit 0
    git push

収集したデータを Git リポジトリにコミットします。[skip ci] を含めることで、無限ループを防いでいます。

データ収集スクリプト

ワークフローから呼び出されるスクリプトを実装します。

typescript// scripts/collect-usage.ts
import { writeFileSync } from 'fs';
import { join } from 'path';
import {
  fetchWorkflowRuns,
  fetchWorkflowRunJobs,
  fetchWorkflowRunUsage,
} from '@/lib/github-api';
import {
  aggregateJobAnalytics,
  aggregateDailyUsage,
} from '@/lib/analytics';

async function main() {
  console.log('Fetching workflow runs...');
  const runs = await fetchWorkflowRuns(100);

  console.log(`Found ${runs.length} workflow runs`);

  const jobs = new Map();
  const usages = new Map();

  // 各ワークフロー実行のジョブと使用状況を取得
  for (const run of runs) {
    const runJobs = await fetchWorkflowRunJobs(run.id);
    const usage = await fetchWorkflowRunUsage(run.id);

    jobs.set(run.id, runJobs);
    usages.set(run.id, usage);

    // API レート制限対策
    await sleep(100);
  }

  console.log('Aggregating data...');
  const jobAnalytics = aggregateJobAnalytics(
    runs,
    jobs,
    usages
  );
  const dailyUsage = aggregateDailyUsage(runs, usages);

  // データを JSON ファイルに保存
  saveData('job-analytics.json', jobAnalytics);
  saveData('daily-usage.json', dailyUsage);

  console.log('Done!');
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function saveData(filename: string, data: unknown): void {
  const path = join(process.cwd(), 'data', filename);
  writeFileSync(path, JSON.stringify(data, null, 2));
  console.log(`Saved ${path}`);
}

main().catch(console.error);

このスクリプトは、API からデータを取得し、集計して JSON ファイルに保存します。API レート制限を考慮して、リクエスト間に 100ms の待機時間を設けていますね。

ステップ 5:ダッシュボードの構築

最後に、収集したデータを可視化するダッシュボードを Next.js で構築します。

ダッシュボードのレイアウト

typescript// app/dashboard/page.tsx
import { readFileSync } from 'fs';
import { join } from 'path';
import type {
  JobAnalytics,
  DailyUsage,
} from '@/types/analytics';
import { JobAnalyticsTable } from '@/components/JobAnalyticsTable';
import { DailyUsageChart } from '@/components/DailyUsageChart';
import { CostSummary } from '@/components/CostSummary';

export default function DashboardPage() {
  const jobAnalytics = loadData<JobAnalytics[]>(
    'job-analytics.json'
  );
  const dailyUsage = loadData<DailyUsage[]>(
    'daily-usage.json'
  );

  return (
    <main className='container mx-auto px-4 py-8'>
      <h1 className='text-3xl font-bold mb-8'>
        GitHub Actions Usage Dashboard
      </h1>

      <CostSummary dailyUsage={dailyUsage} />
      <DailyUsageChart data={dailyUsage} />
      <JobAnalyticsTable data={jobAnalytics} />
    </main>
  );
}

function loadData<T>(filename: string): T {
  const path = join(process.cwd(), 'data', filename);
  const content = readFileSync(path, 'utf-8');
  return JSON.parse(content);
}

ダッシュボードページは 3 つのコンポーネントで構成されます。コストサマリー、日別使用状況グラフ、ジョブ分析テーブルを表示します。

コストサマリーコンポーネント

typescript// components/CostSummary.tsx
import type { DailyUsage } from '@/types/analytics';

interface Props {
  dailyUsage: DailyUsage[];
}

export function CostSummary({ dailyUsage }: Props) {
  const totalCost = dailyUsage.reduce(
    (sum, day) => sum + day.estimatedCost,
    0
  );
  const totalMinutes = dailyUsage.reduce(
    (sum, day) => sum + day.totalBillableMinutes,
    0
  );
  const totalRuns = dailyUsage.reduce(
    (sum, day) => sum + day.totalRuns,
    0
  );

  return (
    <div className='grid grid-cols-1 md:grid-cols-3 gap-4 mb-8'>
      <SummaryCard
        title='総実行回数'
        value={totalRuns.toLocaleString()}
        unit='回'
      />
      <SummaryCard
        title='総実行時間'
        value={Math.round(totalMinutes).toLocaleString()}
        unit='分'
      />
      <SummaryCard
        title='推定コスト'
        value={`$${totalCost.toFixed(2)}`}
        unit=''
      />
    </div>
  );
}
typescript// components/CostSummary.tsx (続き)
interface SummaryCardProps {
  title: string;
  value: string;
  unit: string;
}

function SummaryCard({
  title,
  value,
  unit,
}: SummaryCardProps) {
  return (
    <div className='bg-white rounded-lg shadow p-6'>
      <h3 className='text-gray-500 text-sm font-medium mb-2'>
        {title}
      </h3>
      <p className='text-3xl font-bold text-gray-900'>
        {value}
        <span className='text-lg ml-1'>{unit}</span>
      </p>
    </div>
  );
}

サマリーカードは、重要な指標を大きく表示します。一目で全体のコストが把握できますね。

日別使用状況グラフ

グラフの描画には Recharts ライブラリを使用します。

typescript// components/DailyUsageChart.tsx
'use client';

import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import type { DailyUsage } from '@/types/analytics';

interface Props {
  data: DailyUsage[];
}

export function DailyUsageChart({ data }: Props) {
  return (
    <div className='bg-white rounded-lg shadow p-6 mb-8'>
      <h2 className='text-xl font-bold mb-4'>
        日別実行時間推移
      </h2>

      <ResponsiveContainer width='100%' height={400}>
        <LineChart data={data}>
          <CartesianGrid strokeDasharray='3 3' />
          <XAxis dataKey='date' />
          <YAxis
            label={{
              value: '',
              angle: -90,
              position: 'insideLeft',
            }}
          />
          <Tooltip />
          <Legend />

          <Line
            type='monotone'
            dataKey='ubuntuMinutes'
            stroke='#8b5cf6'
            name='Ubuntu'
          />
          <Line
            type='monotone'
            dataKey='macosMinutes'
            stroke='#3b82f6'
            name='macOS'
          />
          <Line
            type='monotone'
            dataKey='windowsMinutes'
            stroke='#10b981'
            name='Windows'
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

折れ線グラフで OS 別の実行時間推移を可視化します。macOS の使用量が突出している場合は、最適化の余地があることが一目で分かるでしょう。

ジョブ分析テーブル

typescript// components/JobAnalyticsTable.tsx
'use client';

import { useMemo, useState } from 'react';
import type { JobAnalytics } from '@/types/analytics';

interface Props {
  data: JobAnalytics[];
}

type SortKey = keyof JobAnalytics | 'totalBillableMinutes';
type SortOrder = 'asc' | 'desc';

export function JobAnalyticsTable({ data }: Props) {
  const [sortKey, setSortKey] = useState<SortKey>(
    'totalBillableMs'
  );
  const [sortOrder, setSortOrder] =
    useState<SortOrder>('desc');

  const sortedData = useMemo(() => {
    return [...data].sort((a, b) => {
      let aValue: number;
      let bValue: number;

      if (sortKey === 'totalBillableMinutes') {
        aValue = a.totalBillableMs / 1000 / 60;
        bValue = b.totalBillableMs / 1000 / 60;
      } else {
        aValue = a[sortKey] as number;
        bValue = b[sortKey] as number;
      }

      return sortOrder === 'asc'
        ? aValue - bValue
        : bValue - aValue;
    });
  }, [data, sortKey, sortOrder]);

  const handleSort = (key: SortKey) => {
    if (sortKey === key) {
      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
    } else {
      setSortKey(key);
      setSortOrder('desc');
    }
  };

  return (
    <div className='bg-white rounded-lg shadow overflow-hidden'>
      <h2 className='text-xl font-bold p-6 pb-4'>
        ジョブ別分析
      </h2>

      <div className='overflow-x-auto'>
        <table className='min-w-full divide-y divide-gray-200'>
          <TableHeader
            onSort={handleSort}
            sortKey={sortKey}
            sortOrder={sortOrder}
          />
          <TableBody data={sortedData} />
        </table>
      </div>
    </div>
  );
}

テーブルはソート機能を持ち、任意の列でデータを並び替えられます。useMemo でソート処理を最適化していますね。

typescript// components/JobAnalyticsTable.tsx (続き)
interface TableHeaderProps {
  onSort: (key: SortKey) => void;
  sortKey: SortKey;
  sortOrder: SortOrder;
}

function TableHeader({
  onSort,
  sortKey,
  sortOrder,
}: TableHeaderProps) {
  const headers: { key: SortKey; label: string }[] = [
    { key: 'workflowName', label: 'ワークフロー' },
    { key: 'jobName', label: 'ジョブ' },
    { key: 'totalRuns', label: '実行回数' },
    { key: 'successRate', label: '成功率' },
    { key: 'averageDurationMs', label: '平均時間' },
    { key: 'totalBillableMinutes', label: '課金時間' },
  ];

  return (
    <thead className='bg-gray-50'>
      <tr>
        {headers.map(({ key, label }) => (
          <th
            key={key}
            className='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100'
            onClick={() => onSort(key)}
          >
            {label}
            {sortKey === key && (
              <span className='ml-1'>
                {sortOrder === 'asc' ? '↑' : '↓'}
              </span>
            )}
          </th>
        ))}
      </tr>
    </thead>
  );
}

テーブルヘッダーはクリック可能で、ソート方向を示す矢印が表示されます。

typescript// components/JobAnalyticsTable.tsx (続き)
interface TableBodyProps {
  data: JobAnalytics[];
}

function TableBody({ data }: TableBodyProps) {
  return (
    <tbody className='bg-white divide-y divide-gray-200'>
      {data.map((job, index) => (
        <tr key={index} className='hover:bg-gray-50'>
          <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900'>
            {job.workflowName}
          </td>
          <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900'>
            {job.jobName}
          </td>
          <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500'>
            {job.totalRuns}
          </td>
          <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500'>
            {(job.successRate * 100).toFixed(1)}%
          </td>
          <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500'>
            {formatDuration(job.averageDurationMs)}
          </td>
          <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500'>
            {formatMinutes(job.totalBillableMs / 1000 / 60)}
          </td>
        </tr>
      ))}
    </tbody>
  );
}

function formatDuration(ms: number): string {
  const seconds = Math.round(ms / 1000);
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  return `${minutes}${remainingSeconds}秒`;
}

function formatMinutes(minutes: number): string {
  return `${Math.round(minutes)}分`;
}

各行にはジョブの詳細情報が表示され、時間は分秒形式でフォーマットされます。ホバー効果で視認性も向上していますよ。

ステップ 6:パッケージのインストール

必要な依存パッケージをインストールしましょう。

bashyarn add @octokit/rest recharts
yarn add -D @types/node

これで、GitHub API クライアントとグラフ描画ライブラリが利用可能になります。

動作確認

以下の手順でダッシュボードを起動できます。

bash# 初回データ収集
yarn collect-usage

# 開発サーバー起動
yarn dev

ブラウザで http://localhost:3000/dashboard を開くと、以下のような情報が表示されます。

図で理解できる要点:

  • コストサマリーで総実行回数、総実行時間、推定コストを一覧表示
  • 日別グラフで OS ごとの実行時間推移を折れ線グラフで可視化
  • ジョブテーブルでワークフロー・ジョブごとの詳細データをソート可能な表で表示

以下の図は、実際のダッシュボード画面でのデータフローを示しています。

mermaidflowchart LR
  json[("JSON<br/>ファイル")] -->|読込| page["Next.js<br/>ページ"]
  page -->|Props| summary["CostSummary<br/>コンポーネント"]
  page -->|Props| chart["DailyUsageChart<br/>コンポーネント"]
  page -->|Props| table["JobAnalyticsTable<br/>コンポーネント"]

  summary -->|表示| browser["ブラウザ"]
  chart -->|描画| browser
  table -->|描画| browser

この図から分かるように、JSON ファイルからデータを読み込み、各コンポーネントに Props として渡すことで、疎結合で保守しやすい設計になっています。

まとめ

GitHub Actions の実行コストを可視化するダッシュボードシステムを構築しました。このシステムの主なポイントは以下の通りです。

実現できたこと

#機能メリット
1自動データ収集GitHub Actions で毎日自動実行
2ジョブ別分析どのジョブが最もコストを消費しているか把握
3OS 別集計macOS など高コストな環境の使用状況を監視
4時系列可視化コストの推移をグラフで追跡
5インタラクティブ表示ソート機能で多角的に分析可能

最適化のヒント

ダッシュボードでデータを分析すると、以下のような最適化ポイントが見えてきます。

  • macOS ジョブを Linux に移行できないか検討する
  • 実行時間の長いジョブを分割または並列化する
  • 失敗率の高いジョブを改善する
  • キャッシュを活用して実行時間を短縮する
  • 不要なワークフロー実行を減らす(トリガー条件の見直し)

今後の拡張案

このシステムはさらに拡張できます。

  • Slack や Discord への通知機能追加
  • アラート設定(コストが閾値を超えたら通知)
  • より詳細な分析(ステップ別の実行時間など)
  • コスト予測機能(過去のトレンドから将来のコストを予測)
  • 複数リポジトリの統合ダッシュボード

GitHub Actions のコストは放置すると予想外に増大することがあります。しかし、Usage API を活用してデータを可視化すれば、どこに改善の余地があるのかが明確になりますね。

ぜひこのダッシュボードを活用して、効率的な CI/CD パイプラインを構築してください。コスト最適化により、より多くのリソースを本質的な開発作業に集中させることができるでしょう。

関連リンク