If you’re anything like me, chances are you have a lot of GitHub repos — across multiple organizations, side projects, or clients.

At some point you lose track of what’s red, what’s green, which workflows are failing, or whether your CI has silently broken somewhere deep in a forgotten repo. I wanted a single pane of glass view of all CI jobs across all my orgs.

First I looked at GitHub Projects, but Actions are repo-scoped and nothing natively aggregates across orgs. So… I built my own.
A small GitHub App + Action + Pages dashboard that auto-publishes a live status board.


🧩 Step 1 — Create a GitHub App

Go to:

Settings → Developer settings → GitHub Apps → New GitHub App

Give it a name like ci-health-dashboard, and configure:

Permissions (minimum):

  • Repository → Actions → Read-only
  • Repository → Checks → Read-only
  • Repository → Contents → Read-only
  • Repository → Metadata → Read-only

Where can this GitHub App be installed?

  • Choose Any account

Save it, then generate:

  1. A Private key (.pem) — download this file.
  2. Copy the App ID.

Install the app on any orgs you want scanned (you can add more later).


🧰 Step 2 — Create a repo for the dashboard

You can use a personal repo like activity-dashboard or ci-health-dashboard.

Inside that repo:

npm init -y
npm install @octokit/core @octokit/auth-app @octokit/plugin-paginate-rest @octokit/plugin-rest-endpoint-methods

Then add two scripts:

src/scan.js — uses your GitHub App to list all installations, repos, and collect workflow runs.

// src/scan.js
// Scans every org where your GitHub App is installed and writes dashboard/data.json
// Includes build status of tags (from 'tags' or 'releases'), with prefix/regex filtering.

import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/core";
import { paginateRest } from "@octokit/plugin-paginate-rest";
import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
import fs from "node:fs";
import path from "node:path";

const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods);

const APP_ID = process.env.APP_ID;
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const RUN_WINDOW_DAYS = parseInt(process.env.SCANNED_RUN_WINDOW_DAYS || "14", 10);
const MAX_REPOS = process.env.MAX_REPOS ? parseInt(process.env.MAX_REPOS, 10) : null;

// Tag options
const TAG_SOURCE = (process.env.TAG_SOURCE || "tags").toLowerCase(); // "tags" | "releases"
const TAG_PREFIXES = (process.env.TAG_PREFIXES || "")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);
const TAG_REGEX = process.env.TAG_REGEX ? new RegExp(process.env.TAG_REGEX) : null;
const TAG_INCLUDE_PRERELEASES = String(process.env.TAG_INCLUDE_PRERELEASES || "false") === "true";
const TAGS_LIMIT = parseInt(process.env.TAGS_LIMIT || "5", 10);

if (!APP_ID || !PRIVATE_KEY) {
  console.error("Missing APP_ID or PRIVATE_KEY env vars");
  process.exit(1);
}

const appOctokit = new PaginatingOctokit({
  authStrategy: createAppAuth,
  auth: { appId: APP_ID, privateKey: PRIVATE_KEY },
  request: { retries: 2 },
});

const cutoff = new Date(Date.now() - RUN_WINDOW_DAYS * 24 * 60 * 60 * 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function getInstallations() {
  return appOctokit.paginate("GET /app/installations", { per_page: 100 });
}

function installationClient(installationId) {
  return new PaginatingOctokit({
    authStrategy: createAppAuth,
    auth: { appId: APP_ID, privateKey: PRIVATE_KEY, installationId },
    request: { retries: 2 },
  });
}

// Manual pagination & normalization
async function listRepos(instOctokit) {
  let page = 1;
  const repos = [];
  while (true) {
    const resp = await instOctokit.request("GET /installation/repositories", {
      per_page: 100,
      page,
    });
    const items = resp?.data?.repositories || [];
    for (const r of items) {
      repos.push({
        id: r?.id ?? null,
        name: r?.name ?? null,
        full_name:
          r?.full_name ??
          (r?.owner?.login && r?.name ? `${r.owner.login}/${r.name}` : null),
        owner: { login: r?.owner?.login ?? null },
        private: !!r?.private,
        archived: !!r?.archived,
        default_branch: r?.default_branch ?? null,
        html_url: r?.html_url ?? null,
        pushed_at: r?.pushed_at ?? null,
      });
    }
    const link = resp?.headers?.link || "";
    const hasNext = /<[^>]+>; rel="next"/.test(link);
    if (!hasNext || items.length === 0) break;
    page++;
    await sleep(50);
  }
  return repos;
}

// ---------- Tag/release helpers ----------
function namePassesFilters(name) {
  if (!name) return false;
  if (TAG_PREFIXES.length && !TAG_PREFIXES.some((p) => name.startsWith(p))) return false;
  if (TAG_REGEX && !TAG_REGEX.test(name)) return false;
  return true;
}

// Returns array of { name, refForChecks, html_url }
async function listTagRefs(octokit, repo, limit = TAGS_LIMIT) {
  const out = [];

  if (TAG_SOURCE === "releases") {
    let page = 1;
    while (out.length < limit) {
      const r = await octokit.request("GET /repos/{owner}/{repo}/releases", {
        owner: repo.owner.login,
        repo: repo.name,
        per_page: 100,
        page,
      });
      const releases = r?.data || [];
      if (!releases.length) break;

      for (const rel of releases) {
        if (!TAG_INCLUDE_PRERELEASES && (rel?.prerelease || rel?.draft)) continue;
        const tagName = rel?.tag_name;
        if (!namePassesFilters(tagName)) continue;

        out.push({
          name: tagName,
          refForChecks: tagName, // Checks API accepts tag refs
          html_url: rel?.html_url || (repo.html_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tagName)}` : null),
        });
        if (out.length >= limit) break;
      }
      const link = r?.headers?.link || "";
      const hasNext = /<[^>]+>; rel="next"/.test(link);
      if (!hasNext) break;
      page++;
      await sleep(40);
    }
  } else {
    // TAG_SOURCE === "tags"
    let page = 1;
    while (out.length < limit) {
      const r = await octokit.request("GET /repos/{owner}/{repo}/tags", {
        owner: repo.owner.login,
        repo: repo.name,
        per_page: 100,
        page,
      });
      const tags = r?.data || [];
      if (!tags.length) break;

      for (const t of tags) {
        const tagName = t?.name;
        if (!namePassesFilters(tagName)) continue;

        out.push({
          name: tagName,
          refForChecks: tagName, // use tag name as ref
          html_url: repo.html_url ? `${repo.html_url}/tree/${encodeURIComponent(tagName)}` : null,
        });
        if (out.length >= limit) break;
      }
      const link = r?.headers?.link || "";
      const hasNext = /<[^>]+>; rel="next"/.test(link);
      if (!hasNext) break;
      page++;
      await sleep(40);
    }
  }

  return out.slice(0, limit);
}

async function concludeForRef(octokit, repo, ref) {
  // Prefer suites aggregate
  const suitesResp = await octokit.request(
    "GET /repos/{owner}/{repo}/commits/{ref}/check-suites",
    { owner: repo.owner.login, repo: repo.name, ref, per_page: 100 }
  );
  const suites = suitesResp?.data?.check_suites || [];
  if (suites.length) {
    if (suites.some((s) => ["failure", "cancelled", "timed_out"].includes(s?.conclusion || "")))
      return "failure";
    if (suites.every((s) => s?.conclusion === "success")) return "success";
    if (suites.some((s) => (s?.status || "") !== "completed" && !s?.conclusion)) return "in_progress";
    return suites[0]?.conclusion || "unknown";
  }

  // Fallback: runs
  const runsResp = await octokit.request(
    "GET /repos/{owner}/{repo}/commits/{ref}/check-runs",
    { owner: repo.owner.login, repo: repo.name, ref, per_page: 100 }
  );
  const runs = runsResp?.data?.check_runs || [];
  if (runs.length) {
    if (runs.some((r) => ["failure", "cancelled", "timed_out"].includes(r?.conclusion || "")))
      return "failure";
    if (runs.every((r) => r?.conclusion === "success")) return "success";
    if (runs.some((r) => (r?.status || "") !== "completed" && !r?.conclusion)) return "in_progress";
    return runs[0]?.conclusion || "unknown";
  }

  return "unknown";
}

async function fetchTagStatuses(octokit, repo, limit = TAGS_LIMIT) {
  try {
    const refs = await listTagRefs(octokit, repo, limit);
    const out = [];
    for (const ref of refs) {
      const conclusion = await concludeForRef(octokit, repo, ref.refForChecks);
      out.push({
        name: ref.name,
        sha: null, // not required; we used ref
        conclusion,
        html_url: ref.html_url,
      });
      await sleep(25);
    }
    return out;
  } catch (e) {
    return [{ name: null, sha: null, conclusion: "unknown", error: String(e?.message || e) }];
  }
}

// ---------- Workflows / repo health ----------
async function fetchRepoHealth(octokit, repo) {
  if (!repo || !repo.owner?.login || !repo.name) {
    return {
      id: repo?.id ?? null,
      name: repo?.name ?? null,
      full_name: repo?.full_name ?? null,
      error: "Malformed repository object (missing owner/name)",
    };
  }

  const paramsBase = { owner: repo.owner.login, repo: repo.name, per_page: 50 };

  const [runsCompleted, runsInProgress, runsQueued] = await Promise.all([
    octokit.request("GET /repos/{owner}/{repo}/actions/runs", {
      ...paramsBase,
      status: "completed",
    }),
    octokit.request("GET /repos/{owner}/{repo}/actions/runs", {
      ...paramsBase,
      status: "in_progress",
    }),
    octokit.request("GET /repos/{owner}/{repo}/actions/runs", {
      ...paramsBase,
      status: "queued",
    }),
  ]);

  const recent = (runsResp) => {
    const list = runsResp?.data?.workflow_runs || [];
    return list.filter((r) => r && r.created_at && new Date(r.created_at) >= cutoff);
  };

  const completed = recent(runsCompleted);
  const inProgress = recent(runsInProgress);
  const queued = recent(runsQueued);

  // Latest completed run per workflow
  const byWorkflow = new Map();
  for (const run of completed) {
    if (!run || !run.workflow_id) continue;
    const prev = byWorkflow.get(run.workflow_id);
    if (!prev || new Date(run.created_at) > new Date(prev.created_at)) {
      byWorkflow.set(run.workflow_id, run);
    }
  }

  async function failingJobs(latestRun) {
    if (!latestRun || !latestRun.id) return [];
    const jobs = await octokit.paginate(
      "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs",
      { owner: repo.owner.login, repo: repo.name, run_id: latestRun.id, per_page: 100 },
      (resp) => resp?.data?.jobs || []
    );
    return (jobs || [])
      .filter((j) => ["failure", "cancelled", "timed_out"].includes(j?.conclusion || ""))
      .map((j) => ({
        id: j?.id ?? null,
        name: j?.name ?? null,
        html_url: j?.html_url ?? null,
        conclusion: j?.conclusion ?? null,
        started_at: j?.started_at ?? null,
        completed_at: j?.completed_at ?? null,
        duration_ms:
          j?.completed_at && j?.started_at
            ? new Date(j.completed_at) - new Date(j.started_at)
            : null,
      }));
  }

  const workflows = [];
  for (const run of byWorkflow.values()) {
    if (!run) continue;
    const conclusion = run?.conclusion || "";
    const isBad = ["failure", "cancelled", "timed_out"].includes(conclusion);
    workflows.push({
      workflow_id: run.workflow_id,
      workflow_name: run.name || String(run.workflow_id),
      latest_run: {
        id: run.id ?? null,
        event: run.event ?? null,
        html_url: run.html_url ?? null,
        status: run.status ?? null,
        conclusion: run.conclusion ?? null,
        created_at: run.created_at ?? null,
        updated_at: run.updated_at ?? null,
        head_branch: run.head_branch ?? null,
        head_sha: run.head_sha ?? null,
      },
      failing_jobs: isBad ? await failingJobs(run) : [],
    });
    if (isBad) await sleep(60);
  }

  const lastSuccess =
    (completed || [])
      .filter((r) => r && r.conclusion === "success")
      .sort(
        (a, b) => new Date(b.created_at).valueOf() - new Date(a.created_at).valueOf()
      )[0]?.created_at || null;

  // Tag/release statuses
  const tags = await fetchTagStatuses(octokit, repo, TAGS_LIMIT);

  return {
    id: repo.id ?? null,
    name: repo.name ?? null,
    full_name:
      repo.full_name ??
      (repo.owner?.login && repo.name ? `${repo.owner.login}/${repo.name}` : null),
    private: !!repo.private,
    archived: !!repo.archived,
    default_branch: repo.default_branch ?? null,
    html_url: repo.html_url ?? null,
    pushed_at: repo.pushed_at ?? null,
    last_success: lastSuccess,
    workflows,
    tags,
    in_progress: inProgress.length,
    queued: queued.length,
  };
}

async function main() {
  const outDir = path.join(process.cwd(), "dashboard");
  fs.mkdirSync(outDir, { recursive: true });

  const installations = await getInstallations();
  console.log(`Found ${installations.length} installations`);

  const orgs = [];
  let repoCounter = 0;

  for (const inst of installations) {
    const instClient = installationClient(inst.id);
    const repos = await listRepos(instClient);
    console.log(`Installation ${inst.account?.login}${repos.length} repos`);

    const reposOut = [];

    for (const r of repos) {
      if (MAX_REPOS && repoCounter >= MAX_REPOS) break;
      repoCounter++;
      try {
        const health = await fetchRepoHealth(instClient, r);
        reposOut.push(health);
      } catch (e) {
        const display =
          r?.full_name ??
          (r?.owner?.login && r?.name ? `${r.owner.login}/${r.name}` : r?.name ?? "(unknown repo)");
        console.warn(`Repo scan failed: ${display}`, e?.status || e?.message || e);
        reposOut.push({
          id: r?.id ?? null,
          name: r?.name ?? null,
          full_name:
            r?.full_name ??
            (r?.owner?.login && r?.name ? `${r.owner.login}/${r.name}` : null),
          error: String(e?.message || e),
        });
      }
      await sleep(40);
    }

    orgs.push({
      installation_id: inst.id,
      account_login: inst.account?.login ?? null,
      account_type: inst.account?.type ?? null,
      html_url: inst.account?.html_url ?? null,
      repositories: reposOut,
    });
  }

  const payload = {
    generated_at: new Date().toISOString(),
    run_window_days: RUN_WINDOW_DAYS,
    org_count: orgs.length,
    repo_count: orgs.reduce((n, o) => n + o.repositories.length, 0),
    orgs,
  };

  fs.writeFileSync(path.join(outDir, "data.json"), JSON.stringify(payload, null, 2));
  console.log("Wrote dashboard/data.json");
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

src/generateDashboard.js — renders a static HTML dashboard into dashboard/index.html.

// src/generateDashboard.js
// Renders dashboard/index.html with filtering by tag/name/status
// and supports URL query params: ?q=&filter=&tag=&tagstatus=
// (keeps URL updated on input changes)

import fs from "node:fs";
import path from "node:path";

function page() {
  return (
"<!doctype html>\n"+
"<html lang=\"en\">\n"+
"  <head>\n"+
"    <meta charset=\"utf-8\" />\n"+
"    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n"+
"    <title>CI Health Dashboard</title>\n"+
"    <style>\n"+
"      :root { --bg:#0b1020; --card:#111936; --muted:#8aa0b6; --ok:#2ecc71; --bad:#ff6b6b; --warn:#f1c40f; }\n"+
"      *{box-sizing:border-box}\n"+
"      body{margin:0;background:var(--bg);color:#e9f0f6;font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,Helvetica,Arial}\n"+
"      header{padding:20px 24px;border-bottom:1px solid #25314d;display:flex;gap:12px;align-items:center;flex-wrap:wrap}\n"+
"      h1{margin:0;font-size:20px}\n"+
"      .muted{color:var(--muted)}\n"+
"      .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:16px;padding:16px}\n"+
"      .card{background:var(--card);border-radius:12px;padding:14px;border:1px solid #25314d}\n"+
"      .row{display:flex;justify-content:space-between;gap:8px;align-items:center}\n"+
"      input,select{background:#0e1530;color:#dce6f3;border:1px solid #28365b;border-radius:8px;padding:10px}\n"+
"      a{color:#8ab4ff;text-decoration:none}\n"+
"      .pill{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid #2b3e6b;white-space:nowrap}\n"+
"      .ok{background:#13341f;border-color:#1e7f4d}\n"+
"      .bad{background:#3a1212;border-color:#9b2a2a}\n"+
"      .warn{background:#3a300e;border-color:#7a6613}\n"+
"      .tagbar{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}\n"+
"      .tag{border:1px solid #2b3e6b;border-radius:10px;padding:2px 8px;font-size:12px;display:inline-flex;gap:6px;align-items:center}\n"+
"      .dot{width:8px;height:8px;border-radius:999px;display:inline-block}\n"+
"      .dot.ok{background:var(--ok)} .dot.bad{background:var(--bad)} .dot.warn{background:var(--warn)} .dot.unk{background:#7a8aa3}\n"+
"      table{width:100%;border-collapse:collapse;margin-top:10px}\n"+
"      th,td{padding:6px 8px;border-bottom:1px solid #25314d;vertical-align:top}\n"+
"      th{text-align:left}\n"+
"      footer{color:#8aa0b6;padding:12px 16px;border-top:1px solid #25314d}\n"+
"      .section{padding:0 16px}\n"+
"      details{margin:8px 0}\n"+
"      summary{cursor:pointer}\n"+
"      .err{color:#ffb3b3}\n"+
"      .controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center}\n"+
"      .controls label{font-size:12px;color:var(--muted)}\n"+
"    </style>\n"+
"  </head>\n"+
"  <body>\n"+
"    <header>\n"+
"      <h1>CI Health Dashboard</h1>\n"+
"      <div class=\"muted\" id=\"generated\"></div>\n"+
"      <div style=\"flex:1\"></div>\n"+
"      <div class=\"controls\">\n"+
"        <input id=\"q\" placeholder=\"Search repo/workflow…\" />\n"+
"        <select id=\"filter\">\n"+
"          <option value=\"all\">All</option>\n"+
"          <option value=\"failing\">Has failing runs</option>\n"+
"          <option value=\"stale\">No success in window</option>\n"+
"        </select>\n"+
"        <input id=\"tagq\" placeholder=\"Tag contains…\" />\n"+
"        <select id=\"tagstatus\">\n"+
"          <option value=\"any\">Any tag status</option>\n"+
"          <option value=\"success\">Tag status: success</option>\n"+
"          <option value=\"failure\">Tag status: failure</option>\n"+
"          <option value=\"in_progress\">Tag status: in progress</option>\n"+
"          <option value=\"unknown\">Tag status: unknown</option>\n"+
"          <option value=\"has\">Has tags</option>\n"+
"          <option value=\"none\">No tags</option>\n"+
"        </select>\n"+
"      </div>\n"+
"    </header>\n"+
"\n"+
"    <div class=\"section\">\n"+
"      <details id=\"errors-wrap\" open style=\"display:none\">\n"+
"        <summary>Scan errors</summary>\n"+
"        <ul id=\"errors\"></ul>\n"+
"      </details>\n"+
"    </div>\n"+
"\n"+
"    <div class=\"grid\" id=\"grid\"></div>\n"+
"\n"+
"    <footer>\n"+
"      Built from GitHub Actions data. Latest file: <code>dashboard/data.json</code>.\n"+
"    </footer>\n"+
"\n"+
"    <script>\n"+
"      (function(){\n"+
"        function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;'); }\n"+
"        function pill(cls, txt){ return '<span class=\"pill '+cls+'\">'+esc(txt)+'</span>'; }\n"+
"        function dot(cl){ return '<span class=\"dot '+cl+'\"></span>'; }\n"+
"        function tagBadge(tag){\n"+
"          var c = (tag && tag.conclusion) || 'unknown';\n"+
"          var cls = c==='success' ? 'ok' : (c==='failure' ? 'bad' : (c==='in_progress' ? 'warn' : 'unk'));\n"+
"          var name = esc(tag && tag.name || '(tag)');\n"+
"          var href = esc(tag && tag.html_url || '#');\n"+
"          return '<a class=\"tag\" target=\"_blank\" href=\"'+href+'\">'+dot(cls)+'<span>'+name+'</span></a>';\n"+
"        }\n"+
"\n"+
"        // --- URL state helpers ---\n"+
"        function readParams(){\n"+
"          var u = new URL(window.location.href);\n"+
"          return {\n"+
"            q: u.searchParams.get('q') || '',\n"+
"            filter: u.searchParams.get('filter') || 'all',\n"+
"            tag: u.searchParams.get('tag') || '',\n"+
"            tagstatus: u.searchParams.get('tagstatus') || 'any'\n"+
"          };\n"+
"        }\n"+
"        function writeParams(state){\n"+
"          var u = new URL(window.location.href);\n"+
"          var sp = u.searchParams;\n"+
"          function setOrDel(key, val, def){ if(!val || val===def){ sp.delete(key); } else { sp.set(key, val); } }\n"+
"          setOrDel('q', state.q, '');\n"+
"          setOrDel('filter', state.filter, 'all');\n"+
"          setOrDel('tag', state.tag, '');\n"+
"          setOrDel('tagstatus', state.tagstatus, 'any');\n"+
"          var qs = sp.toString();\n"+
"          var newUrl = u.pathname + (qs ? ('?'+qs) : '') + u.hash;\n"+
"          if (newUrl !== window.location.pathname + window.location.search + window.location.hash) {\n"+
"            history.replaceState(null, '', newUrl);\n"+
"          }\n"+
"        }\n"+
"        function debounce(fn, ms){ var t; return function(){ var a=arguments; clearTimeout(t); t=setTimeout(function(){ fn.apply(null,a); }, ms); }; }\n"+
"\n"+
"        function matchesFreeText(repo, term){\n"+
"          if(!term) return true;\n"+
"          var hay=(repo.full_name+' '+(repo.workflows||[]).map(function(w){return w.workflow_name;}).join(' ')).toLowerCase();\n"+
"          return hay.indexOf(term.toLowerCase())>-1;\n"+
"        }\n"+
"        function repoFailing(repo){\n"+
"          return (repo.workflows||[]).some(function(w){ var c=(w.latest_run&&w.latest_run.conclusion)||''; return ['failure','cancelled','timed_out'].indexOf(c)>-1; });\n"+
"        }\n"+
"        function repoStale(repo){ return !repo.last_success; }\n"+
"        function matchesTagName(repo, term){ if(!term) return true; term=term.toLowerCase(); return (repo.tags||[]).some(function(t){ return t && t.name && t.name.toLowerCase().indexOf(term)>-1; }); }\n"+
"        function matchesTagStatus(repo, mode){ var tags=repo.tags||[]; if(mode==='any') return true; if(mode==='has') return tags.length>0; if(mode==='none') return tags.length===0; return tags.some(function(t){ return (t && (t.conclusion||'unknown')===mode); }); }\n"+
"\n"+
"        function cardHtml(repo){\n"+
"          var problems = repoFailing(repo);\n"+
"          var inprog = repo.in_progress||0;\n"+
"          var queued = repo.queued||0;\n"+
"          var html='';\n"+
"          html += \"<div class='card'>\";\n"+
"          html +=   \"<div class='row'>\";\n"+
"          html +=     \"<div>\"+'<a target=\"_blank\" href=\"'+esc(repo.html_url)+'\">'+esc(repo.full_name)+'</a>'+(repo.archived?' '+pill('warn','archived'):'')+\"</div>\";\n"+
"          html +=     \"<div>\"+(problems?pill('bad','failing'):pill('ok','OK'))+(inprog?' '+pill('','in-progress '+inprog):'')+(queued?' '+pill('','queued '+queued):'')+\"</div>\";\n"+
"          html +=   \"</div>\";\n"+
"          if (repo.tags && repo.tags.length){ html += '<div class=\"tagbar\">'+repo.tags.map(tagBadge).join('')+'</div>'; }\n"+
"          html +=   \"<table><thead><tr><th>Workflow</th><th>Status</th><th>When</th></tr></thead><tbody>\";\n"+
"          (repo.workflows||[]).forEach(function(w){\n"+
"            var c=(w.latest_run&&w.latest_run.conclusion) || (w.latest_run&&w.latest_run.status) || 'unknown';\n"+
"            var cls=(['failure','cancelled','timed_out'].indexOf(c)>-1)?'bad':(c==='success'?'ok':'');\n"+
"            var when=(w.latest_run&&w.latest_run.created_at)? new Date(w.latest_run.created_at).toLocaleString() : '';\n"+
"            html += '<tr>'+\n"+
"                      '<td><a target=\"_blank\" href=\"'+esc(w.latest_run&&w.latest_run.html_url)+'\">'+esc(w.workflow_name || w.workflow_id)+'</a></td>'+\n"+
"                      '<td>'+pill(cls, c)+'</td>'+\n"+
"                      '<td class=\"muted\">'+esc(when)+'</td>'+\n"+
"                    '</tr>';\n"+
"            if (w.failing_jobs && w.failing_jobs.length){\n"+
"              html += '<tr><td colspan=\"3\"><div class=\"muted\">Failing jobs:</div><ul>'+\n"+
"                      w.failing_jobs.map(function(j){ return '<li><a target=\"_blank\" href=\"'+esc(j.html_url)+'\">'+esc(j.name)+'</a> ('+esc(j.conclusion)+')</li>'; }).join('')+\n"+
"                      '</ul></td></tr>';\n"+
"            }\n"+
"          });\n"+
"          html +=   \"</tbody></table>\";\n"+
"          html += \"</div>\";\n"+
"          return html;\n"+
"        }\n"+
"\n"+
"        function render(data){\n"+
"          var grid = document.getElementById('grid');\n"+
"          var q = document.getElementById('q');\n"+
"          var filter = document.getElementById('filter');\n"+
"          var tagq = document.getElementById('tagq');\n"+
"          var tagstatus = document.getElementById('tagstatus');\n"+
"          var state = { q: (q.value||'').trim(), filter: filter.value, tag: (tagq.value||'').trim(), tagstatus: tagstatus.value };\n"+
"\n"+
"          var frags = [];\n"+
"          data.orgs.forEach(function(org){\n"+
"            org.repositories.forEach(function(repo){\n"+
"              if (!repo || repo.error) return;\n"+
"              if (!matchesFreeText(repo, state.q)) return;\n"+
"              if (state.filter==='failing' && !repoFailing(repo)) return;\n"+
"              if (state.filter==='stale' && !repoStale(repo)) return;\n"+
"              if (!matchesTagName(repo, state.tag)) return;\n"+
"              if (!matchesTagStatus(repo, state.tagstatus)) return;\n"+
"              frags.push(cardHtml(repo));\n"+
"            });\n"+
"          });\n"+
"\n"+
"          grid.innerHTML = frags.join('') || '<div class=\"muted\">No repositories match your filters.</div>';\n"+
"          writeParams(state);\n"+
"        }\n"+
"\n"+
"        fetch('data.json', {cache:'no-store'})\n"+
"          .then(function(r){return r.json();})\n"+
"          .then(function(data){\n"+
"            document.getElementById('generated').textContent = 'Generated ' + new Date(data.generated_at).toLocaleString() + ' • Orgs: ' + data.org_count + ' • Repos: ' + data.repo_count;\n"+
"\n"+
"            // Errors list\n"+
"            var errorsWrap=document.getElementById('errors-wrap');\n"+
"            var errorsList=document.getElementById('errors');\n"+
"            var allErrors=[]; data.orgs.forEach(function(org){ org.repositories.forEach(function(repo){ if(repo && repo.error) allErrors.push(repo); }); });\n"+
"            if(allErrors.length){ errorsWrap.style.display=''; errorsList.innerHTML = allErrors.map(function(e){ return '<li class=\"err\">'+esc(e.full_name||e.name||'(unknown)')+': '+esc(e.error)+'</li>'; }).join(''); }\n"+
"\n"+
"            // Initialize controls from URL\n"+
"            var initial = readParams();\n"+
"            var q=document.getElementById('q'); var filter=document.getElementById('filter'); var tagq=document.getElementById('tagq'); var tagstatus=document.getElementById('tagstatus');\n"+
"            q.value = initial.q; filter.value = initial.filter; tagq.value = initial.tag; tagstatus.value = initial.tagstatus;\n"+
"\n"+
// debounced inputs
"            var debouncedRender = debounce(function(){ render(data); }, 200);\n"+
"            q.addEventListener('input', debouncedRender);\n"+
"            tagq.addEventListener('input', debouncedRender);\n"+
"            filter.addEventListener('change', function(){ render(data); });\n"+
"            tagstatus.addEventListener('change', function(){ render(data); });\n"+
"\n"+
"            render(data);\n"+
"          });\n"+
"      })();\n"+
"    </script>\n"+
"  </body>\n"+
"</html>\n"
  );
}

function main() {
  const outDir = path.join(process.cwd(), "dashboard");
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
  fs.writeFileSync(path.join(outDir, "index.html"), page());
  console.log("Wrote dashboard/index.html");
}

main();

🧱 Step 3 — GitHub Action to scan and publish

In .github/workflows/scan-and-publish.yml:

name: Scan orgs with GitHub App & publish dashboard

on:
  workflow_dispatch:
  schedule:
    - cron: "17 */6 * * *" # every 6 hours

permissions:
  contents: read
  actions: read
  checks: read
  pages: write
  id-token: write

concurrency:
  group: app-scan
  cancel-in-progress: false

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    environment:
      name: github-pages

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install deps
        run: npm ci

      - name: Scan installations → dashboard/
        env:
          APP_ID: ${{ secrets.APP_ID }}
          PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
          SCANNED_RUN_WINDOW_DAYS: 14
          # Optional tag filters:
          # TAG_SOURCE: "releases"
          # TAG_PREFIXES: "v,release-"
          # TAG_REGEX: "^v\\d+\\.\\d+"
          # TAG_INCLUDE_PRERELEASES: "false"
          # TAGS_LIMIT: "5"
        run: |
          node src/scan.js
          node src/generateDashboard.js

      - name: Configure Pages
        uses: actions/configure-pages@v5

      - name: Upload artifact for GitHub Pages
        uses: actions/upload-pages-artifact@v3
        with:
          path: dashboard

      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

Then add two secrets in your repo’s Settings → Secrets → Actions:

  • APP_ID (The APP_ID from the created app)
  • PRIVATE_KEY (paste the .pem contents)

💻 Step 4 — The dashboard

Every 6 hours (or on manual trigger), the workflow runs your app, scans all installations, collects:

  • Repo list
  • Workflow runs (success/failure/cancelled)
  • Failing jobs
  • In-progress / queued counts
  • Tag / release build statuses

Then it writes to dashboard/data.json and renders dashboard/index.html.

The index.html includes:

  • Filters for text, status, tag name, tag status
  • URL parameters (?q=&filter=&tag=&tagstatus=) so you can share filtered views
  • A dark theme grid with colored pills and tags for quick health scanning

It’s completely static — no backend required.

🪄 Step 5 — Publish to Pages

Go to your repo’s Settings → Pages, set:

  • Source: “GitHub Actions”
  • Branch: leave blank (Actions handles it)

After your first successful workflow run, your dashboard will appear at:

https://<username>.github.io/<reponame>/

You can bookmark it or share filtered views, like:

?filter=failing&tag=v1

⚙️ Advanced options

You can tune scans with environment vars:

VariablePurposeExample
SCANNED_RUN_WINDOW_DAYSLimit to recent runs14
MAX_REPOSStop after N repos100
TAG_SOURCEUse tags or releasesreleases
TAG_PREFIXESComma-separated allowed prefixesv,release-
TAG_REGEXRegex to match tags^v\\d+\\.\\d+
TAG_INCLUDE_PRERELEASESInclude pre-releasesfalse
TAGS_LIMITHow many tags to check5

🧠 Why build this?

Because the more teams and repos you manage, the harder it gets to see the forest for the trees. I didn’t want yet another SaaS tool or dashboard service — I wanted a self-contained, GitHub-native view I can trust and host for free.

Now, at a glance, I know:

  • Which workflows are failing or stale
  • Which tags/releases broke
  • Which repos haven’t had a successful build recently

…and it all runs on GitHub infrastructure.

✅ Summary

  • Create a GitHub App with read permissions
  • Install it on all orgs
  • Store its creds as Action secrets
  • Add scan.js, generateDashboard.js, and the workflow above
  • Enable Pages
  • Enjoy your all-in-one CI health board 😎