Compare commits

..
5 Commits
Author SHA1 Message Date
lukaszraczylo b74396f005 fix(collector): link a failed repository to the configured host
When the first scan of a repository failed, the repository link pointed at github.com. It now follows github.api_url: Enterprise Server hosts drop the /api/v3 suffix and Enterprise Cloud hosts drop the api. prefix.
2026-10-02 10:15:48 +01:00
lukaszraczylo e004751d88 docs: add the product page for ghtracker.raczylo.com
The page follows the kportal page: a hero with the install command, a dashboard walkthrough with screenshots from public repositories, feature rows, the alert rules, install methods, set-up steps, the metric list, a reference, troubleshooting and a FAQ.

A workflow deploys docs/ to GitHub Pages. Changes under docs/ no longer start a release.
2026-10-02 10:04:24 +01:00
lukaszraczylo 0c7c782d86 fix(gh): confirm failing workflows on a new connection
GitHub sometimes answers a workflow run listing from a lagging replica. For one workflow the answer held 135 runs and ended three weeks ago, while the current listing held 414 runs. The client reuses one connection, so a stale replica could answer every request of a scan and the dashboard showed a failure that a newer run had already replaced.

A failing workflow is now requested again on a new connection that skips the response cache. The client keeps the union of both answers, so the newest run decides.
2026-10-02 05:24:46 +01:00
lukaszraczylo a56a12a6e0 feat(ui): add a progress loading screen and lazy project list
The loading screen shows live scan progress from the server, with a pulsing outline of the dashboard. The server now reports how many repositories the running scan has finished.

The project list renders 20 rows first and adds more as the end scrolls into view. Groups on very wide screens fill two balanced columns, the fact figures sit in a row under the health strip, and the headline counts match the rows listed below it.
2026-10-02 04:50:38 +01:00
lukaszraczylo 1852ea0885 fix(ui): use the full page width and fit phone screens
The page no longer caps its width. On very wide screens the attention list shows two columns of projects.

On phones the header, the health strip and the filter chips wrap or scroll inside their own box, and each item shows its age under the text. The side panel scrolls on its own.
2026-10-02 04:40:27 +01:00
37 changed files with 1776 additions and 95 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy the product page to Pages
on:
push:
branches: ['main']
paths:
- 'docs/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# One deployment at a time. Do not cancel a deployment that is running.
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: docs/
- id: deployment
uses: actions/deploy-pages@v4
+2
View File
@@ -7,6 +7,8 @@ on:
- main
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/workflows/pages.yaml'
- '.github/workflows/ci.yaml'
permissions:
+1
View File
@@ -0,0 +1 @@
ghtracker.raczylo.com
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="310" height="82" viewBox="0 0 310 82" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ghtracker">
<g transform="translate(0 0)"><rect x="0" y="22" width="10" height="38" rx="1.5" fill="#ff6a5c"/><rect x="14" y="22" width="10" height="38" rx="1.5" fill="#ff6a5c"/><rect x="28" y="22" width="10" height="38" rx="1.5" fill="#ff6a5c"/><rect x="42" y="22" width="10" height="38" rx="1.5" fill="#f2b43a"/><rect x="56" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="70" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="84" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="98" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="112" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/></g>
<text x="140" y="56" font-family="Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif" font-size="38" font-weight="700" letter-spacing="-1" fill="#e8eaed">ghtracker</text>
</svg>

After

Width:  |  Height:  |  Size: 969 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="310" height="82" viewBox="0 0 310 82" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ghtracker">
<g transform="translate(0 0)"><rect x="0" y="22" width="10" height="38" rx="1.5" fill="#c4291c"/><rect x="14" y="22" width="10" height="38" rx="1.5" fill="#c4291c"/><rect x="28" y="22" width="10" height="38" rx="1.5" fill="#c4291c"/><rect x="42" y="22" width="10" height="38" rx="1.5" fill="#b87400"/><rect x="56" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="70" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="84" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="98" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="112" y="22" width="10" height="38" rx="1.5" fill="#17794d"/></g>
<text x="140" y="56" font-family="Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif" font-size="38" font-weight="700" letter-spacing="-1" fill="#14171a">ghtracker</text>
</svg>

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+903
View File
@@ -0,0 +1,903 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ghtracker: see which GitHub repositories need attention</title>
<meta name="description" content="ghtracker scans the GitHub repositories you list through a GitHub App and shows what is failing: workflows, pull requests that wait too long, stale issues. Dashboard and Prometheus metrics in one binary. MIT licence.">
<link rel="canonical" href="https://ghtracker.raczylo.com/">
<meta name="color-scheme" content="dark light">
<meta property="og:type" content="website">
<meta property="og:site_name" content="ghtracker">
<meta property="og:title" content="ghtracker: see which GitHub repositories need attention">
<meta property="og:description" content="Failing workflows, waiting pull requests and stale issues across your GitHub repositories. Prometheus metrics included. MIT licence.">
<meta property="og:url" content="https://ghtracker.raczylo.com/">
<meta property="og:image" content="https://ghtracker.raczylo.com/ghtracker-screenshot-dark.png">
<meta property="og:image:alt" content="ghtracker dashboard: 10 critical alerts and 1 warning across 20 projects, a health strip with one segment per project, and failing workflows and pull requests grouped by repository">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ghtracker: see which GitHub repositories need attention">
<meta name="twitter:description" content="Failing workflows, waiting pull requests and stale issues across your GitHub repositories. Prometheus metrics included.">
<meta name="twitter:image" content="https://ghtracker.raczylo.com/ghtracker-screenshot-dark.png">
<link rel="icon" href="ghtracker-logo-dark.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "ghtracker",
"description": "Dashboard and Prometheus metrics for GitHub repositories. Scans a list of repositories through a GitHub App and shows failing workflows, pull requests that wait too long and stale issues.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Linux, macOS, Windows",
"programmingLanguage": ["Go", "TypeScript"],
"url": "https://ghtracker.raczylo.com/",
"downloadUrl": "https://github.com/lukaszraczylo/ghtracker/releases",
"codeRepository": "https://github.com/lukaszraczylo/ghtracker",
"license": "https://github.com/lukaszraczylo/ghtracker/blob/main/LICENSE",
"isAccessibleForFree": true,
"image": "https://ghtracker.raczylo.com/ghtracker-screenshot-dark.png",
"author": { "@type": "Person", "name": "Lukasz Raczylo" }
}
</script>
<script>
(function () {
var d = document.documentElement;
d.classList.add("js");
try { var t = localStorage.getItem("ghtracker-theme"); if (t === "light" || t === "dark") d.setAttribute("data-theme", t); } catch (e) {}
})();
</script>
<style>
:root {
--bg: #0d0e10;
--surface: #14161a;
--surface-2: #1b1e23;
--line: #2a2e35;
--fg: #e8eaed;
--fg-dim: #c4c9d0;
--muted: #9aa2ad;
--accent: #7cb7ff;
--on-accent: #0d0e10;
--code-str: #d6dae0;
--sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--bg: #ffffff; --surface: #f6f7f8; --surface-2: #eceef1; --line: #d3d8de;
--fg: #14171a; --fg-dim: #343b43; --muted: #555f6a; --accent: #0b5bd3; --on-accent: #ffffff; --code-str: #2b3138;
color-scheme: light;
}
}
:root[data-theme="light"] {
--bg: #ffffff; --surface: #f6f7f8; --surface-2: #eceef1; --line: #d3d8de;
--fg: #14171a; --fg-dim: #343b43; --muted: #555f6a; --accent: #0b5bd3; --on-accent: #ffffff; --code-str: #2b3138;
color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 400 1rem/1.6 var(--sans); overflow-wrap: break-word; }
img { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); text-underline-offset: 0.2em; }
a:hover { text-decoration-thickness: 2px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
code, kbd, pre { font-family: var(--mono); font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; }
p code, li code, td code, dd code, th code, summary code { background: var(--surface-2); border: 1px solid var(--line); padding: 0.05em 0.35em; font-size: 0.88em; overflow-wrap: anywhere; }
kbd { background: var(--surface-2); border: 1px solid var(--line); border-bottom-width: 2px; padding: 0.05em 0.4em; font-size: 0.85em; white-space: nowrap; }
.wrap { width: 100%; max-width: 74rem; margin: 0 auto; padding: 0 1.25rem; }
.skip { position: absolute; left: 0.5rem; top: -4rem; background: var(--accent); color: var(--on-accent); padding: 0.5rem 0.9rem; z-index: 100; font-weight: 600; }
.skip:focus { top: 0.5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* header */
.site-header { position: sticky; top: 0; z-index: 50; background: var(--bg); border-bottom: 1px solid var(--line); }
.site-header .wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 0 1.25rem; }
.brand { display: flex; align-items: center; padding: 0.4rem 0; }
.brand img { height: 3.1rem; width: auto; }
.logo-dark { display: block; }
.logo-light { display: none; }
@media (prefers-color-scheme: light) { :root:not([data-theme]) .logo-dark { display: none; } :root:not([data-theme]) .logo-light { display: block; } }
:root[data-theme="light"] .logo-dark { display: none; }
:root[data-theme="light"] .logo-light { display: block; }
.head-actions { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
.head-actions a { color: var(--fg-dim); text-decoration: none; font-size: 0.92rem; font-weight: 500; padding: 0.5rem 0.25rem; }
.head-actions a:hover { color: var(--fg); text-decoration: underline; }
.icon-btn { font: 500 0.85rem var(--sans); color: var(--fg-dim); background: transparent; border: 1px solid var(--line); padding: 0.4rem 0.75rem; min-height: 2.25rem; cursor: pointer; }
.icon-btn:hover { color: var(--fg); border-color: var(--muted); }
.primary-nav { order: 3; width: 100%; display: flex; gap: 0.25rem 1.1rem; overflow-x: auto; white-space: nowrap; padding-bottom: 0.1rem; scrollbar-width: thin; }
.primary-nav a { color: var(--muted); text-decoration: none; font-size: 0.92rem; font-weight: 500; padding: 0.55rem 0; border-bottom: 2px solid transparent; }
.primary-nav a:hover { color: var(--fg); border-bottom-color: var(--accent); }
@media (min-width: 62rem) {
.primary-nav { order: 1; width: auto; flex: 1; padding: 0; overflow: visible; }
.primary-nav a { padding: 1.05rem 0; }
.head-actions { order: 2; margin-left: 0; }
}
/* sections */
section[id], .scroll-target { scroll-margin-top: 7rem; }
.section { padding: 4rem 0; border-top: 1px solid var(--line); }
.section-head { max-width: 46rem; margin-bottom: 2rem; }
.eyebrow { font: 500 0.78rem var(--mono); color: var(--accent); letter-spacing: 0.06em; text-transform: uppercase; margin: 0 0 0.75rem; }
h1, h2, h3, h4 { line-height: 1.2; margin: 0; letter-spacing: -0.01em; }
h2 { font-size: clamp(1.7rem, 4.2vw, 2.4rem); font-weight: 650; }
h3 { font-size: 1.3rem; font-weight: 600; }
h4 { font-size: 1.02rem; font-weight: 600; margin: 1.5rem 0 0.5rem; }
.section-head p:not(.eyebrow) { color: var(--muted); font-size: 1.08rem; margin: 0.9rem 0 0; }
p { margin: 0 0 1rem; }
ul, ol { margin: 0 0 1rem; padding-left: 1.2rem; }
li { margin: 0.3rem 0; }
.muted { color: var(--muted); }
.small { font-size: 0.9rem; }
/* hero */
.hero { padding: 3rem 0 3.5rem; }
.hero-grid { display: grid; gap: 2.5rem; grid-template-columns: minmax(0, 1fr); align-items: start; }
@media (min-width: 62rem) { .hero-grid { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 3rem; } .hero { padding: 4.5rem 0 4.5rem; } }
h1 { font-size: clamp(2.1rem, 6.4vw, 3.4rem); font-weight: 700; letter-spacing: -0.025em; line-height: 1.08; }
.lead { font-size: 1.12rem; color: var(--fg-dim); margin: 1.25rem 0 1.75rem; max-width: 36rem; }
.btn-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 1.5rem 0 1.75rem; }
.btn { display: inline-block; font: 600 0.95rem var(--sans); padding: 0.7rem 1.25rem; border: 1px solid var(--accent); text-decoration: none; min-height: 2.75rem; }
.btn-primary { background: var(--accent); color: var(--on-accent); }
.btn-primary:hover { text-decoration: underline; }
.btn-secondary { background: transparent; color: var(--accent); }
.btn-secondary:hover { background: var(--surface-2); }
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 0; border: 1px solid var(--line); max-width: 36rem; }
.facts div { padding: 0.65rem 0.9rem; border-bottom: 1px solid var(--line); }
.facts div:nth-child(odd) { border-right: 1px solid var(--line); }
.facts div:nth-last-child(-n+2) { border-bottom: 0; }
.facts dt { font: 500 0.72rem var(--mono); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.facts dd { margin: 0.15rem 0 0; font-size: 0.95rem; font-weight: 500; }
/* code + terminal */
.code { position: relative; background: var(--surface); border: 1px solid var(--line); margin: 0 0 1rem; min-width: 0; }
.code-head { font: 500 0.78rem var(--mono); color: var(--muted); padding: 0.55rem 5rem 0.55rem 1rem; border-bottom: 1px solid var(--line); background: var(--surface-2); }
.code pre, .term pre { margin: 0; padding: 1rem; overflow-x: auto; font-size: 0.82rem; line-height: 1.6; tab-size: 2; }
.code[data-copy] pre { padding-right: 5rem; }
.code[data-copy] .code-head + pre { padding-right: 1rem; }
.code code { color: var(--code-str); }
.k { color: var(--accent); }
.c { color: var(--muted); font-style: italic; }
.copy { position: absolute; top: 0.45rem; right: 0.45rem; font: 500 0.75rem var(--sans); color: var(--fg-dim); background: var(--bg); border: 1px solid var(--line); padding: 0.25rem 0.6rem; min-height: 1.75rem; cursor: pointer; z-index: 2; }
.copy:hover { color: var(--fg); border-color: var(--muted); }
.copy.done { color: var(--accent); border-color: var(--accent); }
.term { --t-bg: #0b0c0e; --t-fg: #d9dce1; --t-mut: #8d949e; --t-acc: #7cb7ff; background: var(--t-bg); border: 1px solid var(--line); margin: 0 0 1rem; min-width: 0; color: var(--t-fg); }
.term-bar { display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.9rem; border-bottom: 1px solid #262a30; }
.term-bar i { width: 0.65rem; height: 0.65rem; border-radius: 50%; background: #3a3f47; display: block; }
.term-bar span { margin-left: 0.5rem; font: 500 0.75rem var(--mono); color: var(--t-mut); }
.term code { color: var(--t-fg); }
.term .h { color: var(--t-acc); font-weight: 600; }
.term .m { color: var(--t-mut); }
.term .a { color: var(--t-acc); }
.term .sel { background: #23272e; display: inline-block; min-width: 100%; }
.term .b { font-weight: 600; }
.hero .term pre { font-size: 0.8rem; }
.shot-frame { border: 1px solid var(--line); background: #0b0c0e; margin: 0; }
.shot-frame img { width: 100%; }
figcaption { font-size: 0.88rem; color: var(--muted); padding: 0.7rem 1rem; border-top: 1px solid var(--line); background: var(--surface); }
/* feature rows */
.feature { display: grid; gap: 1.5rem 3rem; grid-template-columns: minmax(0, 1fr); padding: 2.75rem 0; border-top: 1px solid var(--line); align-items: start; }
.feature:first-of-type { border-top: 0; padding-top: 0.5rem; }
.feature .text p, .feature .text li { color: var(--fg-dim); }
.feature .text h3 { margin-bottom: 0.9rem; }
.feature .text .tag { font: 500 0.75rem var(--mono); color: var(--accent); margin: 0 0 0.6rem; letter-spacing: 0.04em; text-transform: uppercase; }
.feature .demo > :last-child { margin-bottom: 0; }
.feature .demo .caption { font-size: 0.85rem; color: var(--muted); margin: -0.35rem 0 1rem; }
@media (min-width: 62rem) {
.feature { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
.feature.rev .text { order: 2; }
.feature.rev .demo { order: 1; }
}
/* tables */
.tablewrap { overflow-x: auto; border: 1px solid var(--line); margin: 0 0 1rem; background: var(--bg); }
table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.93rem; }
th, td { text-align: left; vertical-align: top; padding: 0.65rem 0.9rem; border-bottom: 1px solid var(--line); }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
thead th { background: var(--surface); font-weight: 600; white-space: nowrap; position: relative; }
tbody th { font-weight: 500; background: var(--bg); }
.tablewrap.wrap-cells td, .tablewrap.wrap-cells th { white-space: normal; }
.cmp { min-width: 54rem; }
.cmp thead th:first-child, .cmp tbody th { position: sticky; left: 0; z-index: 1; border-right: 1px solid var(--line); min-width: 11.5rem; max-width: 14rem; }
.cmp thead th:first-child { z-index: 2; background: var(--surface); }
.cmp td { min-width: 9.5rem; }
.cmp thead th.us, .cmp td.us { background: var(--surface); }
.y { color: var(--accent); font-weight: 600; }
.p { color: var(--fg); font-weight: 500; }
.nd { color: var(--muted); font-style: italic; }
.no { color: var(--fg); }
.cell-note { display: block; font-size: 0.78rem; color: var(--muted); font-weight: 400; font-style: normal; margin-top: 0.15rem; }
.cmp-meta { display: grid; gap: 1.5rem 3rem; grid-template-columns: minmax(0, 1fr); margin-top: 2rem; }
@media (min-width: 52rem) { .cmp-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.note { border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface); padding: 0.8rem 1rem; margin: 0 0 1.5rem; font-size: 0.95rem; }
.note p:last-child { margin-bottom: 0; }
/* install tabs */
.tablist { display: flex; flex-wrap: wrap; gap: 0; border-bottom: 1px solid var(--line); margin-bottom: 1.5rem; overflow-x: auto; }
.tablist button { font: 500 0.92rem var(--sans); color: var(--muted); background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 0.75rem 1.1rem; margin-bottom: -1px; cursor: pointer; white-space: nowrap; min-height: 2.75rem; }
.tablist button:hover { color: var(--fg); }
.tablist button[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); }
.tabpanel { margin-bottom: 2rem; }
.js .tabpanel { margin-bottom: 0; }
.js .tabpanel > h3 { display: none; }
.tabpanel > h3 { font-size: 1.1rem; margin-bottom: 0.75rem; }
.tabpanel p { color: var(--fg-dim); max-width: 46rem; }
.tabpanel .code, .tabpanel .term { max-width: 52rem; }
/* misc layouts */
.two { display: grid; gap: 1.5rem 3rem; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 52rem) { .two { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.steps { counter-reset: s; list-style: none; padding: 0; margin: 0 0 1.5rem; }
.steps li { counter-increment: s; position: relative; padding: 0.15rem 0 0.9rem 2.75rem; color: var(--fg-dim); }
.steps li::before { content: counter(s); position: absolute; left: 0; top: 0.1rem; width: 1.9rem; height: 1.9rem; border: 1px solid var(--line); font: 500 0.82rem/1.8rem var(--mono); text-align: center; color: var(--accent); background: var(--surface); }
details { border-top: 1px solid var(--line); padding: 0.9rem 0; }
details:last-of-type { border-bottom: 1px solid var(--line); }
summary { cursor: pointer; font-weight: 600; list-style: none; padding-right: 1.5rem; position: relative; }
summary::-webkit-details-marker { display: none; }
summary::after { content: "+"; position: absolute; right: 0; top: 0; font-family: var(--mono); color: var(--accent); }
details[open] summary::after { content: "\2212"; }
details p, details ul { color: var(--fg-dim); margin-top: 0.75rem; max-width: 46rem; }
.status-dot { font-family: var(--mono); white-space: nowrap; }
.inline-list { list-style: none; padding: 0; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: 0.4rem 0.5rem; }
.inline-list li { margin: 0; }
.inline-list code { display: inline-block; }
/* footer */
.site-footer { border-top: 1px solid var(--line); padding: 2.5rem 0 3rem; color: var(--muted); font-size: 0.92rem; }
.foot-grid { display: grid; gap: 1.75rem 3rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 52rem) { .foot-grid { grid-template-columns: 2fr 1fr 1fr 1fr; } }
.site-footer h2 { font: 600 0.8rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg); margin: 0 0 0.75rem; }
.site-footer ul { list-style: none; padding: 0; margin: 0; }
.site-footer li { margin: 0.35rem 0; }
.site-footer a { color: var(--fg-dim); }
.foot-brand { grid-column: 1 / -1; }
@media (min-width: 52rem) { .foot-brand { grid-column: auto; } }
.foot-bottom { border-top: 1px solid var(--line); margin-top: 2rem; padding-top: 1.25rem; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
@media (max-width: 40rem) {
.section { padding: 3rem 0; }
.feature { padding: 2rem 0; }
.code pre, .term pre { font-size: 0.78rem; }
}
/* ghtracker additions */
.shot { margin: 0; border: 1px solid var(--line); background: var(--surface); }
.shot picture, .shot img { display: block; width: 100%; }
.numbers { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); border: 1px solid var(--line); margin: 0 0 1rem; }
.numbers div { padding: 0.9rem 1rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: -1px 0 0 -1px; }
.numbers dt { font: 500 0.78rem var(--mono); color: var(--muted); }
.numbers dd { margin: 0.25rem 0 0; font-size: 1.05rem; font-weight: 600; }
.sev-crit { color: #ff6a5c; font-weight: 600; } .sev-warn { color: #f2b43a; font-weight: 600; } .sev-ok { color: #4cc38a; font-weight: 600; }
:root[data-theme="light"] .sev-crit { color: #c4291c; } :root[data-theme="light"] .sev-warn { color: #8f5b00; } :root[data-theme="light"] .sev-ok { color: #17794d; }
@media (prefers-color-scheme: light) { :root:not([data-theme]) .sev-crit { color: #c4291c; } :root:not([data-theme]) .sev-warn { color: #8f5b00; } :root:not([data-theme]) .sev-ok { color: #17794d; } }
</style></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="#top" aria-label="ghtracker home">
<img class="logo-dark" src="ghtracker-logo-dark.svg" alt="ghtracker" width="310" height="82">
<img class="logo-light" src="ghtracker-logo-light.svg" alt="" width="310" height="82">
</a>
<div class="head-actions">
<a href="https://github.com/lukaszraczylo/ghtracker">GitHub</a>
<button type="button" class="icon-btn" id="theme-toggle" hidden aria-label="Switch colour theme">Theme</button>
</div>
<nav class="primary-nav" aria-label="Sections">
<a href="#interface">Dashboard</a>
<a href="#features">Features</a>
<a href="#alerts">Alerts</a>
<a href="#install">Install</a>
<a href="#setup">Set up</a>
<a href="#metrics">Metrics</a>
<a href="#reference">Reference</a>
<a href="#troubleshooting">Troubleshooting</a>
<a href="#faq">FAQ</a>
</nav>
</div>
</header>
<main id="main">
<!-- hero -->
<section class="hero" id="top">
<div class="wrap hero-grid">
<div>
<p class="eyebrow">GitHub repository dashboard</p>
<h1>See which of your GitHub repositories need attention</h1>
<p class="lead">ghtracker scans the repositories you list through a GitHub App. It shows only what is wrong: failing workflows, pull requests that wait too long and stale issues. One binary serves the dashboard and Prometheus metrics.</p>
<div class="code" data-copy><div class="code-head">Docker</div><pre tabindex="0"><code>docker run <span class="k">--rm</span> <span class="k">-p</span> 8080:8080 \
<span class="k">-v</span> "$PWD/config.yaml:/config/config.yaml:ro" \
<span class="k">-e</span> GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
ghcr.io/lukaszraczylo/ghtracker:latest</code></pre></div>
<p class="small muted">Binaries, Kubernetes and building from source: <a href="#install">Install</a>. You need a GitHub App first: <a href="#setup">Set up</a>.</p>
<div class="btn-row">
<a class="btn btn-primary" href="https://github.com/lukaszraczylo/ghtracker">View on GitHub</a>
<a class="btn btn-secondary" href="#reference">Documentation</a>
</div>
<dl class="facts">
<div><dt>Licence</dt><dd>MIT</dd></div>
<div><dt>Language</dt><dd>Go and Vue</dd></div>
<div><dt>Platforms</dt><dd>Linux, macOS, Windows</dd></div>
<div><dt>Container image</dt><dd>amd64, arm64</dd></div>
</dl>
</div>
<div>
<figure class="shot">
<picture>
<source srcset="ghtracker-hero-light.png" media="(prefers-color-scheme: light)">
<img src="ghtracker-hero-dark.png" width="760" height="860" alt="ghtracker dashboard: 10 critical alerts and 1 warning across 20 projects, a health strip with one segment per project, and failing workflows and pull requests grouped by repository">
</picture>
</figure>
<p class="small muted">Example data: 20 public repositories of the author, scanned in October 2026.</p>
</div>
</div>
</section>
<!-- dashboard -->
<section class="section" id="interface" aria-labelledby="interface-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Dashboard</p>
<h2 id="interface-h">Healthy repositories stay out of the way</h2>
<p>A long list of green items hides the red ones. ghtracker lists only items that need attention, grouped by repository, and keeps one line per repository in a side panel for everything else.</p>
</div>
<figure class="shot">
<picture>
<source srcset="ghtracker-screenshot-light.png" media="(prefers-color-scheme: light)">
<img src="ghtracker-screenshot-dark.png" width="1440" height="1180" alt="ghtracker dashboard: 10 critical alerts and 1 warning across 20 projects, a health strip with one segment per project, and failing workflows and pull requests grouped by repository" loading="lazy">
</picture>
<figcaption>The same page at desktop width, with 20 public repositories of the author.</figcaption>
</figure>
<ol class="steps" style="margin-top:2rem">
<li><strong>Headline.</strong> The count of critical items and warnings, and how many projects they come from.</li>
<li><strong>Health strip.</strong> One segment per project, red, amber or green. Select a segment to focus that project. Segments that do not match the current search or filters dim.</li>
<li><strong>Five figures.</strong> Workflows failing, pull requests waiting, pull requests with failing checks, stale issues and data problems.</li>
<li><strong>Needs attention.</strong> One block per repository with its latest release. Each row links to the workflow run, pull request or issue, and shows its age. A meter beside a waiting pull request fills towards the critical age.</li>
<li><strong>Projects.</strong> Every project with its latest release and age, and counts of workflows, pull requests and issues, each linked to GitHub.</li>
</ol>
<p class="muted">The page uses the full width, works on phones and follows the colour scheme of the system. While the first scan runs it shows how many repositories are done.</p>
</div>
</section>
<!-- features -->
<section class="section" id="features" aria-labelledby="features-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Features</p>
<h2 id="features-h">What ghtracker does</h2>
<p>Every snippet below comes from the repository: the source, <code>config.example.yaml</code> or the Kubernetes example.</p>
</div>
<article class="feature">
<div class="text">
<p class="tag">Workflows</p>
<h3>Failing workflows, including background ones</h3>
<p>For every active workflow ghtracker takes the newest run on the default branch. A failed run is critical. Scheduled, dynamic and manually started workflows carry a <code>background</code> mark, because nobody is watching them run.</p>
<p>Pull request runs appear as checks on the pull request instead. A workflow that has not run for 30 days is muted, so an old failure does not raise an alert for ever.</p>
</div>
<div class="demo">
<div class="code"><div class="code-head">Example rows</div><pre tabindex="0"><code><span class="k">critical</span> lukaszraczylo/git-velocity
Git Velocity Analysis (background)
failure on main (schedule) 4d
<span class="k">critical</span> lukaszraczylo/gohoarder
Release
failure on main (push) 29h</code></pre></div>
</div>
</article>
<article class="feature rev">
<div class="text">
<p class="tag">Pull requests</p>
<h3>Pull requests that wait too long</h3>
<p>A non-draft pull request is a warning after 48 hours and critical after 7 days. Drafts are ignored. A pull request with failing checks is a warning, and its check names appear in the row.</p>
<p>Set the limits with <code>thresholds</code>. A negative value turns a check off.</p>
</div>
<div class="demo">
<div class="code" data-copy><div class="code-head">config.yaml</div><pre tabindex="0"><code><span class="k">thresholds</span>:
pr_warn_after: 48h
pr_crit_after: 168h
issue_stale_after: 720h
workflow_stuck_after: 6h
workflow_dormant_after: 720h</code></pre></div>
</div>
</article>
<article class="feature">
<div class="text">
<p class="tag">Search and filters</p>
<h3>Search, and filters that show their own counts</h3>
<p>Type to match projects, versions, titles and details. Every word must match. Press <kbd>/</kbd> to jump to the search box and <kbd>Esc</kbd> to clear it.</p>
<p>Filter by severity, kind (workflows, pull requests, issues, data) and owner. Each chip shows how many items it would leave given the other filters, and a chip that would match nothing is disabled. The address holds the filters, so you can share a view.</p>
</div>
<div class="demo">
<div class="code" data-copy><div class="code-head">Address of a filtered view</div><pre tabindex="0"><code>https://ghtracker.example.com/?q=bump&amp;kind=pr&amp;owner=lukaszraczylo&amp;sev=crit</code></pre></div>
<p class="caption">Search for "bump", pull requests only, one owner, critical only.</p>
</div>
</article>
<article class="feature rev">
<div class="text">
<p class="tag">Releases</p>
<h3>Latest release of every project, one click away</h3>
<p>Each project shows its newest GitHub release with a link and its age. A project without a release shows its newest tag. The same data is available as the metric <code>ghtracker_release_info</code>.</p>
</div>
<div class="demo">
<div class="code"><div class="code-head">Example series</div><pre tabindex="0"><code>ghtracker_release_info{<span class="k">repo</span>="lukaszraczylo/kportal",
<span class="k">version</span>="v0.11.2",
<span class="k">url</span>="https://github.com/lukaszraczylo/kportal/releases/tag/v0.11.2"} 1
ghtracker_release_published_timestamp_seconds{<span class="k">repo</span>="lukaszraczylo/kportal"} 1.790906487e+09</code></pre></div>
</div>
</article>
<article class="feature">
<div class="text">
<p class="tag">Metrics</p>
<h3>Prometheus metrics for alerting</h3>
<p>Metrics are computed when you scrape, so a pull request age keeps growing between scans. Per-repository gauges cover health, open pull requests and issues, workflow state and the age of the oldest pull request.</p>
<p>The Kubernetes example ships alert rules for failing workflows, pull requests waiting over 3 days, repositories that fail to scan, stale scans and a low GitHub API budget.</p>
</div>
<div class="demo">
<div class="code" data-copy><div class="code-head">Prometheus rules</div><pre tabindex="0"><code>- alert: GhtrackerWorkflowFailing
expr: ghtracker_workflow_failing == 1
for: 10m
- alert: GhtrackerPRWaiting
expr: ghtracker_pr_age_seconds &gt; 3 * 86400
- alert: GhtrackerStale
expr: time() - ghtracker_last_refresh_timestamp_seconds &gt; 13 * 3600</code></pre></div>
</div>
</article>
<article class="feature rev">
<div class="text">
<p class="tag">GitHub API</p>
<h3>Careful with your rate limit</h3>
<ul>
<li>Conditional requests with <code>ETag</code>. A <code>304</code> reply does not count against the primary limit.</li>
<li>Requests stop while fewer than 100 remain, until the window resets.</li>
<li>On a primary or secondary limit response ghtracker waits for <code>Retry-After</code> or the reset time, stops the scan and keeps the last good data.</li>
<li>Two repositories at a time by default. Pagination stops at <code>max_items</code>.</li>
</ul>
</div>
<div class="demo">
<div class="code"><div class="code-head">Cost of one scan</div><pre tabindex="0"><code>requests per scan =
6 per repository
+ 2 per open pull request
+ 1 per active workflow
63 repositories take about 100 seconds
and a few hundred requests</code></pre></div>
<p class="caption">Measured on 63 repositories with the default settings. The installation limit of a GitHub App is at least 5,000 requests per hour.</p>
</div>
</article>
<article class="feature">
<div class="text">
<p class="tag">Accuracy</p>
<h3>A failure is confirmed before it alerts</h3>
<p>GitHub sometimes answers a workflow run listing from a lagging replica. One answer held 135 runs ending three weeks ago while the current listing held 414. A client that reuses its connection can keep hitting that replica.</p>
<p>ghtracker asks again on a new connection, without its response cache, for every failing workflow. It keeps the union of both answers, so the newest run decides.</p>
</div>
<div class="demo">
<div class="code"><div class="code-head">Example from a real repository</div><pre tabindex="0"><code>stale answer runs: 135 newest: 09-11 failure
current answer runs: 414 newest: 10-02 success
shown after the check: success</code></pre></div>
</div>
</article>
<article class="feature rev">
<div class="text">
<p class="tag">On demand</p>
<h3>Rescan with one button</h3>
<p>The <strong>Rescan repositories</strong> button starts a new scan and follows it, so the new data appears as soon as the scan ends. One rescan every 5 minutes is allowed, and the server refuses requests that come from other sites. Set <code>manual_refresh: false</code> to turn the button off.</p>
</div>
<div class="demo">
<div class="code" data-copy><div class="code-head">Same thing from a shell</div><pre tabindex="0"><code>curl -X POST http://localhost:8080/refresh</code></pre></div>
<p class="caption">Answers <code>202</code>. A second call within 5 minutes answers <code>429</code>.</p>
</div>
</article>
<article class="feature">
<div class="text">
<p class="tag">One binary</p>
<h3>The dashboard is inside the binary</h3>
<p>The Vue app is built into the Go binary, so there is nothing else to host. The container image is a static binary on a distroless base that runs as a non-root user. Release checksums and image manifests are signed with cosign.</p>
</div>
<div class="demo">
<div class="code" data-copy><div class="code-head">Command line</div><pre tabindex="0"><code>ghtracker -version
ghtracker -config config.yaml</code></pre></div>
</div>
</article>
<article class="feature rev">
<div class="text">
<p class="tag">Kubernetes</p>
<h3>Example manifests for a cluster</h3>
<p>The repository has a Kustomize base with a Deployment, a Service, a ConfigMap, an External Secrets resource for the private key, a Traefik route, a ServiceMonitor and a PrometheusRule. Keep one replica, because each replica scans GitHub on its own.</p>
</div>
<div class="demo">
<div class="code" data-copy><div class="code-head">Review before you apply</div><pre tabindex="0"><code>kustomize build deploy/kubernetes | kubectl diff -f -</code></pre></div>
<p class="caption">The dashboard has no login of its own. Put it behind your ingress authentication.</p>
</div>
</article>
</div>
</section>
<!-- alerts -->
<section class="section" id="alerts" aria-labelledby="alerts-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Alerts</p>
<h2 id="alerts-h">How ghtracker decides what is wrong</h2>
<p>Alerts are evaluated every time you read the page or scrape the metrics, so ages keep advancing between scans. Draft pull requests and archived repositories raise no work alerts.</p>
</div>
<div class="tablewrap wrap-cells">
<table>
<thead><tr><th scope="col">Condition</th><th scope="col">Severity</th><th scope="col">Setting</th></tr></thead>
<tbody>
<tr><td>The newest completed default-branch run of a workflow failed</td><td><span class="sev-crit">critical</span></td><td><code>thresholds.workflow_dormant_after</code> mutes workflows idle longer than this</td></tr>
<tr><td>A scan of a repository failed. The dashboard keeps the last good data.</td><td><span class="sev-crit">critical</span></td><td></td></tr>
<tr><td>A non-draft pull request is older than the critical age</td><td><span class="sev-crit">critical</span></td><td><code>thresholds.pr_crit_after</code>, default 168h</td></tr>
<tr><td>A non-draft pull request is older than the warning age</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.pr_warn_after</code>, default 48h</td></tr>
<tr><td>The checks of a pull request fail</td><td><span class="sev-warn">warning</span></td><td></td></tr>
<tr><td>A workflow is queued or running for too long</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.workflow_stuck_after</code>, default 6h</td></tr>
<tr><td>Open issues without activity. One alert for each repository.</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.issue_stale_after</code>, default 720h</td></tr>
<tr><td>A section failed to load, for example a missing App permission</td><td><span class="sev-warn">warning</span></td><td></td></tr>
<tr><td>The data of a repository is older than expected</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.data_stale_after</code>, default twice the interval plus 30 minutes</td></tr>
</tbody>
</table>
</div>
<p class="muted small">Durations use Go syntax such as <code>48h</code>. There is no <code>d</code> unit.</p>
</div>
</section>
<!-- install -->
<section class="section" id="install" aria-labelledby="install-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Install</p>
<h2 id="install-h">Install ghtracker</h2>
<p>Pick one method. Release archives cover Linux, macOS and Windows on amd64 and arm64.</p>
</div>
<div data-tabs>
<div class="tabpanel" id="tab-docker" data-tab-title="Docker">
<h3>Docker</h3>
<div class="code" data-copy><pre tabindex="0"><code>docker run <span class="k">--rm</span> <span class="k">-p</span> 8080:8080 \
<span class="k">-v</span> "$PWD/config.yaml:/config/config.yaml:ro" \
<span class="k">-e</span> GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
ghcr.io/lukaszraczylo/ghtracker:latest</code></pre></div>
<p>The config must set <code>listen: ":8080"</code> so the port is reachable from outside the container. Tags are the release version, for example <code>0.2.3</code>, and <code>latest</code>.</p>
</div>
<div class="tabpanel" id="tab-binary" data-tab-title="Binary">
<h3>Release archive</h3>
<p>The <a href="https://github.com/lukaszraczylo/ghtracker/releases">releases page</a> has one archive per platform, named <code>ghtracker-&lt;os&gt;-&lt;arch&gt;.tar.gz</code> (<code>.zip</code> on Windows). Each holds the binary, the licence, the README and <code>config.example.yaml</code>.</p>
<div class="code" data-copy><pre tabindex="0"><code>tar -xzf ghtracker-linux-amd64.tar.gz
./ghtracker -config config.yaml</code></pre></div>
</div>
<div class="tabpanel" id="tab-k8s" data-tab-title="Kubernetes">
<h3>Kubernetes</h3>
<p>Copy <code>deploy/kubernetes/</code> from the repository, set your App ID, repositories, secret store and host, then review the result before you apply it.</p>
<div class="code" data-copy><pre tabindex="0"><code>git clone https://github.com/lukaszraczylo/ghtracker.git
kustomize build ghtracker/deploy/kubernetes | kubectl diff -f -</code></pre></div>
<p>If your cluster uses GitOps, commit the manifests and let your controller apply them.</p>
</div>
<div class="tabpanel" id="tab-source" data-tab-title="Build from source">
<h3>Build from source</h3>
<div class="code" data-copy><pre tabindex="0"><code>git clone https://github.com/lukaszraczylo/ghtracker.git
cd ghtracker
make build</code></pre></div>
<p>The build needs Go 1.25 or later, Node 22.13 or later and pnpm 11. <code>make build</code> installs the UI packages, builds the UI into <code>dist/</code> and then builds the Go binary, which embeds <code>dist/</code>. Without the UI build the server answers <code>503</code> at <code>/</code>.</p>
</div>
<div class="tabpanel" id="tab-verify" data-tab-title="Verify signatures">
<h3>Verify a release signature</h3>
<p>Release checksums are signed with <a href="https://github.com/sigstore/cosign">cosign</a> using keyless signing. Download the checksums file and its sigstore bundle from the release, then run:</p>
<div class="code" data-copy><pre tabindex="0"><code>cosign verify-blob \
<span class="k">--certificate-identity-regexp</span> "^https://github\.com/lukaszraczylo/shared-actions/\.github/workflows/go-release\.yaml@refs/heads/main$" \
<span class="k">--certificate-oidc-issuer</span> "https://token.actions.githubusercontent.com" \
<span class="k">--bundle</span> ghtracker-checksums.txt.sigstore.json \
ghtracker-checksums.txt</code></pre></div>
<p>Then check the archive against the verified checksums file with <code>shasum -a 256</code> or <code>sha256sum</code>.</p>
</div>
</div>
</div>
</section>
<!-- set up -->
<section class="section" id="setup" aria-labelledby="setup-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Set up</p>
<h2 id="setup-h">From install to the first dashboard</h2>
<p>ghtracker signs in as a GitHub App. An App can only read what you grant it and you can install it on a user account or an organisation.</p>
</div>
<ol class="steps">
<li>Create a GitHub App with <strong>read-only</strong> repository permissions: Metadata, Contents, Issues, Pull requests, Actions, Checks and Commit statuses. It needs no webhook and no write access.</li>
<li>Install the App on every account or organisation whose repositories you list. Choose all repositories, or select the ones you want.</li>
<li>Generate a private key for the App and keep the <code>.pem</code> file safe.</li>
<li>Copy <code>config.example.yaml</code> to <code>config.yaml</code>, set <code>github.app_id</code> and list your repositories.</li>
<li>Give ghtracker the key through <code>github.private_key_file</code> or the environment variable <code>GHTRACKER_PRIVATE_KEY</code>, then start it and open <code>http://localhost:8080</code>.</li>
</ol>
<div class="code" data-copy><div class="code-head">config.yaml</div><pre tabindex="0"><code><span class="k">listen</span>: ":8080"
<span class="k">refresh_interval</span>: 6h
<span class="k">github</span>:
<span class="k">app_id</span>: 123456
<span class="k">private_key_file</span>: /secrets/ghtracker.pem
<span class="k">installation_id</span>: 0 <span class="c"># 0 = find the installation for each repository</span>
<span class="k">repos</span>:
- lukaszraczylo/kubemirror
- lukaszraczylo/semver-generator</code></pre></div>
<div class="note">
<p><strong>Public and private repositories.</strong> GitHub lets any installation read public data, so public repositories work even with fewer permissions. Private repositories need every permission above, and each installation must accept the update after you add one.</p>
</div>
</div>
</section>
<!-- metrics -->
<section class="section" id="metrics" aria-labelledby="metrics-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Metrics</p>
<h2 id="metrics-h">Prometheus metrics</h2>
<p>All metrics use the <code>ghtracker_</code> prefix and are computed at scrape time. A scrape interval of one minute is enough. Reading the metrics costs no GitHub requests.</p>
</div>
<div class="tablewrap wrap-cells">
<table>
<thead><tr><th scope="col">Metric</th><th scope="col">Labels</th><th scope="col">Meaning</th></tr></thead>
<tbody>
<tr><td><code>repo_up</code>, <code>repo_health</code></td><td>repo</td><td>Last scan succeeded. Worst alert: 0 ok, 1 warning, 2 critical.</td></tr>
<tr><td><code>repo_open_issues</code>, <code>repo_open_prs</code></td><td>repo</td><td>Open issues and open pull requests, including drafts.</td></tr>
<tr><td><code>repo_open_prs_failing_checks</code>, <code>repo_stale_issues</code></td><td>repo</td><td>Pull requests with failing checks. Issues without recent activity.</td></tr>
<tr><td><code>repo_oldest_pr_age_seconds</code></td><td>repo</td><td>Age of the oldest non-draft pull request.</td></tr>
<tr><td><code>pr_age_seconds</code>, <code>pr_checks_failing</code></td><td>repo, number</td><td>One series per non-draft pull request.</td></tr>
<tr><td><code>workflow_failing</code>, <code>workflow_running</code></td><td>repo, workflow, background</td><td>The newest completed run failed. A run is in progress.</td></tr>
<tr><td><code>workflow_last_run_timestamp_seconds</code></td><td>repo, workflow, background</td><td>When the newest default-branch run started.</td></tr>
<tr><td><code>release_info</code></td><td>repo, version, url</td><td>Latest release or tag. The value is always 1.</td></tr>
<tr><td><code>release_published_timestamp_seconds</code></td><td>repo</td><td>When the latest release was published.</td></tr>
<tr><td><code>alerts</code></td><td>severity</td><td>Active alerts of each severity.</td></tr>
<tr><td><code>refreshes_total</code>, <code>repo_refresh_errors_total</code></td><td></td><td>Completed scans. Scan failures of single repositories.</td></tr>
<tr><td><code>last_refresh_timestamp_seconds</code>, <code>last_refresh_duration_seconds</code></td><td></td><td>When the last scan started and how long it took.</td></tr>
<tr><td><code>github_rate_limit_remaining</code></td><td></td><td>Requests left in the current GitHub window.</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- reference -->
<section class="section" id="reference" aria-labelledby="reference-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Reference</p>
<h2 id="reference-h">Reference</h2>
</div>
<h3 id="cli">Command line and environment</h3>
<div class="tablewrap wrap-cells">
<table>
<thead><tr><th scope="col">Name</th><th scope="col">Effect</th></tr></thead>
<tbody>
<tr><td><code>-config &lt;path&gt;</code></td><td>Path of the YAML config file. Default <code>config.yaml</code>.</td></tr>
<tr><td><code>-version</code></td><td>Print the version and exit.</td></tr>
<tr><td><code>GHTRACKER_PRIVATE_KEY</code></td><td>The App private key as PEM text. It takes precedence over <code>github.private_key_file</code>.</td></tr>
</tbody>
</table>
</div>
<h3 id="config-ref" style="margin-top:2.5rem">Configuration keys</h3>
<div class="tablewrap wrap-cells">
<table>
<thead><tr><th scope="col">Key</th><th scope="col">Default</th><th scope="col">Meaning</th></tr></thead>
<tbody>
<tr><td><code>listen</code></td><td><code>:8080</code></td><td>Address of the HTTP server.</td></tr>
<tr><td><code>refresh_interval</code></td><td><code>6h</code></td><td>Time between scans. The minimum is <code>1m</code>.</td></tr>
<tr><td><code>manual_refresh</code></td><td><code>true</code></td><td>The rescan button and <code>POST /refresh</code>.</td></tr>
<tr><td><code>github.app_id</code></td><td></td><td>Required. The numeric ID of the GitHub App.</td></tr>
<tr><td><code>github.private_key_file</code></td><td></td><td>Path of the private key. Required unless <code>GHTRACKER_PRIVATE_KEY</code> is set.</td></tr>
<tr><td><code>github.installation_id</code></td><td><code>0</code></td><td>Use one installation for every repository, or <code>0</code> to find the installation of each repository.</td></tr>
<tr><td><code>github.api_url</code></td><td><code>https://api.github.com</code></td><td>API address. GitHub Enterprise Server has not been tested.</td></tr>
<tr><td><code>thresholds.*</code></td><td>see <a href="#alerts">Alerts</a></td><td>Ages and durations that raise alerts. A negative value disables a check.</td></tr>
<tr><td><code>max_items</code></td><td><code>100</code></td><td>Most issues and pull requests read for each repository.</td></tr>
<tr><td><code>concurrency</code></td><td><code>2</code></td><td>Repositories scanned at the same time.</td></tr>
<tr><td><code>repos</code></td><td></td><td>Required. A list of <code>owner/name</code>.</td></tr>
</tbody>
</table>
</div>
<h3 id="endpoints" style="margin-top:2.5rem">HTTP endpoints</h3>
<div class="tablewrap wrap-cells">
<table>
<thead><tr><th scope="col">Path</th><th scope="col">Answer</th></tr></thead>
<tbody>
<tr><td><code>/</code></td><td>The dashboard.</td></tr>
<tr><td><code>/api/state</code></td><td>The state as JSON: repositories, alerts, thresholds and scan progress.</td></tr>
<tr><td><code>/healthz</code></td><td><code>200</code> after the first scan finished, <code>503</code> before.</td></tr>
<tr><td><code>/metrics</code></td><td>Prometheus metrics.</td></tr>
<tr><td><code>POST /refresh</code></td><td>Starts a scan. Allowed once every 5 minutes. Same-site requests only.</td></tr>
</tbody>
</table>
</div>
<p class="muted small">The dashboard keeps its data in memory. After a restart it scans again, and the page shows the progress.</p>
</div>
</section>
<!-- troubleshooting -->
<section class="section" id="troubleshooting" aria-labelledby="trouble-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Troubleshooting</p>
<h2 id="trouble-h">Common failures</h2>
</div>
<div class="two">
<div>
<h3>HTTP 403: Resource not accessible by integration</h3>
<p>The row says <strong>Incomplete data</strong> for a private repository. The App lacks a permission. Grant read access to Issues, Actions, Checks and Commit statuses as well as the others in <a href="#setup">Set up</a>, then accept the update on every installation. Restart ghtracker, because installation tokens last an hour.</p>
<h3 style="margin-top:2rem">github rate limit reached</h3>
<p>ghtracker stopped the scan on purpose and keeps the last good data. It tries again at the next scan. Lower the number of repositories, raise <code>refresh_interval</code> or lower <code>concurrency</code>. The metric <code>ghtracker_github_rate_limit_remaining</code> shows the budget.</p>
</div>
<div>
<h3>The page says "Scanning repositories" for a long time</h3>
<p>The first scan has to read every repository. Sixty repositories take about two minutes. The page shows how many are done and the container is ready before the scan ends.</p>
<h3 style="margin-top:2rem">UI is not built</h3>
<p>The server answers <code>503</code> at <code>/</code> when you built the binary without the UI. Run <code>make build</code>, or <code>pnpm build</code> before <code>go build</code>.</p>
<h3 style="margin-top:2rem">Config errors at start</h3>
<p>ghtracker names the problem and exits, for example <code>repos must list at least one owner/name</code>, <code>github.app_id is required</code> or <code>refresh_interval 30s is below the 1m0s minimum</code>.</p>
</div>
</div>
</div>
</section>
<!-- faq -->
<section class="section" id="faq" aria-labelledby="faq-h">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">FAQ</p>
<h2 id="faq-h">Questions and answers</h2>
</div>
<details><summary>Does ghtracker change anything on GitHub?</summary><p>No. The client sends only <code>GET</code> requests, apart from the request that exchanges the App key for an installation token. Give the App read-only permissions.</p></details>
<details><summary>Does it need webhooks?</summary><p>No. It polls on a schedule, so it works behind a firewall and needs no public address.</p></details>
<details><summary>Why every six hours?</summary><p>It is the default and keeps the cost of a scan low. Set <code>refresh_interval</code> down to one minute, or use the rescan button when you need fresh data.</p></details>
<details><summary>Can I run more than one replica?</summary><p>It is not useful. Each replica scans GitHub on its own and spends API budget, and the data lives in memory.</p></details>
<details><summary>Is there a login?</summary><p>No. Put the dashboard behind a reverse proxy or an identity provider.</p></details>
<details><summary>Which repositories can it read?</summary><p>Any repository that an installation of your App can access, public or private. A repository outside every installation fails its scan and shows as critical.</p></details>
<details><summary>What is the licence?</summary><p>MIT.</p></details>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<div class="foot-grid">
<div class="foot-brand">
<p style="color:var(--fg);font-weight:600;margin-bottom:0.5rem">ghtracker</p>
<p>GitHub repository dashboard and Prometheus metrics. MIT licence.</p>
</div>
<div>
<h2>Project</h2>
<ul>
<li><a href="https://github.com/lukaszraczylo/ghtracker">GitHub</a></li>
<li><a href="https://github.com/lukaszraczylo/ghtracker/releases">Releases</a></li>
<li><a href="https://github.com/lukaszraczylo/ghtracker/issues">Issues</a></li>
<li><a href="https://github.com/lukaszraczylo/ghtracker/pkgs/container/ghtracker">Container image</a></li>
</ul>
</div>
<div>
<h2>Docs</h2>
<ul>
<li><a href="#setup">Set up</a></li>
<li><a href="#alerts">Alerts</a></li>
<li><a href="#reference">Reference</a></li>
<li><a href="#troubleshooting">Troubleshooting</a></li>
</ul>
</div>
<div>
<h2>Legal</h2>
<ul>
<li><a href="https://github.com/lukaszraczylo/ghtracker/blob/main/LICENSE">MIT licence</a></li>
</ul>
</div>
</div>
<div class="foot-bottom">
<p>Made by Lukasz Raczylo. Built with <a href="https://go.dev">Go</a>, <a href="https://vuejs.org">Vue</a> and the <a href="https://prometheus.io">Prometheus</a> client library.</p>
</div>
</div>
</footer>
<script>
(function () {
var root = document.documentElement;
// Theme toggle
var tbtn = document.getElementById("theme-toggle");
if (tbtn) {
tbtn.hidden = false;
var current = function () {
var t = root.getAttribute("data-theme");
if (t) return t;
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
};
var label = function () { tbtn.textContent = current() === "dark" ? "Light theme" : "Dark theme"; };
label();
tbtn.addEventListener("click", function () {
var next = current() === "dark" ? "light" : "dark";
root.setAttribute("data-theme", next);
try { localStorage.setItem("ghtracker-theme", next); } catch (e) {}
label();
});
}
// Copy buttons
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
return new Promise(function (resolve, reject) {
var ta = document.createElement("textarea");
ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy") ? resolve() : reject(); } catch (e) { reject(e); } finally { document.body.removeChild(ta); }
});
}
Array.prototype.forEach.call(document.querySelectorAll(".code[data-copy]"), function (box) {
var code = box.querySelector("pre code");
if (!code) return;
var b = document.createElement("button");
b.type = "button"; b.className = "copy"; b.textContent = "Copy";
b.setAttribute("aria-label", "Copy code to clipboard");
b.addEventListener("click", function () {
copyText(code.textContent).then(function () {
b.textContent = "Copied"; b.classList.add("done");
setTimeout(function () { b.textContent = "Copy"; b.classList.remove("done"); }, 1600);
}, function () { b.textContent = "Press Ctrl+C"; setTimeout(function () { b.textContent = "Copy"; }, 1600); });
});
box.appendChild(b);
});
// Install tabs
Array.prototype.forEach.call(document.querySelectorAll("[data-tabs]"), function (group) {
var panels = Array.prototype.slice.call(group.querySelectorAll(".tabpanel"));
if (!panels.length) return;
var list = document.createElement("div");
list.className = "tablist"; list.setAttribute("role", "tablist"); list.setAttribute("aria-label", "Install methods");
var tabs = panels.map(function (p, i) {
var t = document.createElement("button");
t.type = "button"; t.setAttribute("role", "tab"); t.id = "t-" + p.id;
t.setAttribute("aria-controls", p.id); t.textContent = p.getAttribute("data-tab-title");
p.setAttribute("role", "tabpanel"); p.setAttribute("aria-labelledby", t.id);
t.addEventListener("click", function () { select(i, true); });
t.addEventListener("keydown", function (e) {
var n = null;
if (e.key === "ArrowRight") n = (i + 1) % tabs.length;
else if (e.key === "ArrowLeft") n = (i - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") n = 0;
else if (e.key === "End") n = tabs.length - 1;
if (n !== null) { e.preventDefault(); select(n, true); }
});
list.appendChild(t);
return t;
});
function select(idx, focus) {
tabs.forEach(function (t, i) {
var on = i === idx;
t.setAttribute("aria-selected", on ? "true" : "false");
t.tabIndex = on ? 0 : -1;
panels[i].hidden = !on;
});
if (focus) tabs[idx].focus();
}
group.insertBefore(list, group.firstChild);
var start = 0;
var h = location.hash.replace("#", "");
panels.forEach(function (p, i) { if (p.id === h) start = i; });
select(start, false);
});
})();
</script>
</body>
</html>
+12 -1
View File
@@ -33,6 +33,9 @@ type View struct {
Warn int
Loaded bool
Refreshing bool
// ScanDone and ScanTotal count the repos of the running (or last) scan.
ScanDone int
ScanTotal int
}
// Stats are monotonic counters and timings of the refresh loop.
@@ -51,11 +54,14 @@ type Collector struct {
repos map[string]model.Repo
now func() time.Time
log *slog.Logger
webBase string
kick chan struct{}
names []string
th config.Thresholds
interval time.Duration
refreshes atomic.Uint64
scanDone atomic.Int64
scanTotal atomic.Int64
repoErrs atomic.Uint64
lastDur time.Duration
concurrency int
@@ -73,6 +79,7 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
interval: cfg.RefreshInterval,
concurrency: cfg.Concurrency,
now: time.Now,
webBase: config.WebBase(cfg.GitHub.APIURL),
log: log,
kick: make(chan struct{}, 1),
repos: make(map[string]model.Repo, len(cfg.Repos)),
@@ -118,6 +125,8 @@ func (c *Collector) Refresh(ctx context.Context) {
start := c.now()
c.limited.Store(false)
c.scanDone.Store(0)
c.scanTotal.Store(int64(len(c.names)))
results := make([]model.Repo, len(c.names))
sem := make(chan struct{}, c.concurrency)
var wg sync.WaitGroup
@@ -128,6 +137,7 @@ func (c *Collector) Refresh(ctx context.Context) {
defer wg.Done()
defer func() { <-sem }()
results[i] = c.refreshOne(ctx, name, start)
c.scanDone.Add(1)
}()
}
wg.Wait()
@@ -166,7 +176,7 @@ func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) m
prev, ok := c.repos[name]
c.mu.RUnlock()
if !ok {
prev = model.Repo{FullName: name, URL: "https://github.com/" + name}
prev = model.Repo{FullName: name, URL: c.webBase + "/" + name}
}
prev.Up, prev.Err = false, err.Error()
return prev
@@ -193,6 +203,7 @@ func (c *Collector) View() View {
v := View{
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
ScanDone: int(c.scanDone.Load()), ScanTotal: int(c.scanTotal.Load()),
}
v.Repos = make([]model.Repo, 0, len(c.names))
for _, n := range c.names {
+39
View File
@@ -312,3 +312,42 @@ func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
t.Fatalf("got %q", got)
}
}
func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{}, 4)
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
started <- struct{}{}
<-release
return model.Repo{FullName: name}, nil
}}
c := newCollector(f, "o/a", "o/b", "o/c")
if v := c.View(); v.ScanDone != 0 || v.ScanTotal != 0 {
t.Fatalf("before any scan: %+v", v)
}
done := make(chan struct{})
go func() { c.Refresh(context.Background()); close(done) }()
<-started
if v := c.View(); v.ScanTotal != 3 || v.ScanDone != 0 || !v.Refreshing {
t.Fatalf("during scan: done=%d total=%d refreshing=%v", v.ScanDone, v.ScanTotal, v.Refreshing)
}
close(release)
<-done
if v := c.View(); v.ScanDone != 3 || v.ScanTotal != 3 {
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
}
}
func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
fail := &fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}
cfg := &config.Config{
Repos: []string{"o/a"}, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th(),
GitHub: config.GitHub{APIURL: "https://ghe.example.com/api/v3"},
}
c := New(fail, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
c.Refresh(context.Background())
if got := c.View().Repos[0].URL; got != "https://ghe.example.com/o/a" {
t.Fatalf("repo link = %q", got)
}
}
+22
View File
@@ -4,6 +4,7 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
@@ -161,3 +162,24 @@ func normalizeRepos(in []string) ([]string, error) {
}
return out, nil
}
const (
defaultWebURL = "https://github.com"
apiV3Suffix = "/api/v3"
apiHostPrefix = "api."
)
// WebBase returns the address of the web interface that belongs to a GitHub API address:
// github.com for the public API, the host without /api/v3 for Enterprise Server, and the host
// without its "api." prefix for Enterprise Cloud with data residency.
func WebBase(apiURL string) string {
u, err := url.Parse(strings.TrimRight(apiURL, "/"))
if err != nil || u.Host == "" || strings.EqualFold(u.Host, "api.github.com") {
return defaultWebURL
}
u.Path = strings.TrimSuffix(u.Path, apiV3Suffix)
if u.Path == "" {
u.Host = strings.TrimPrefix(u.Host, apiHostPrefix)
}
return strings.TrimRight(u.String(), "/")
}
+19
View File
@@ -126,3 +126,22 @@ func TestManualRefreshCanBeDisabled(t *testing.T) {
t.Fatal("explicit false must stay off")
}
}
func TestWebBase(t *testing.T) {
tests := map[string]string{
"": "https://github.com",
"https://api.github.com": "https://github.com",
"https://api.github.com/": "https://github.com",
"https://ghe.example.com/api/v3": "https://ghe.example.com",
"https://ghe.example.com/api/v3/": "https://ghe.example.com",
"http://ghe.internal:8080/api/v3": "http://ghe.internal:8080",
"https://api.acme.ghe.com": "https://acme.ghe.com",
"https://github.example.com/sub/api/v3": "https://github.example.com/sub",
"not a url": "https://github.com",
}
for in, want := range tests {
if got := WebBase(in); got != want {
t.Errorf("WebBase(%q) = %q, want %q", in, got, want)
}
}
}
+41 -8
View File
@@ -66,6 +66,8 @@ type TokenSource interface {
}
type Client struct {
// freshHC opens a new connection for every request.
freshHC *http.Client
rateReset time.Time
blockedUntil time.Time
auth TokenSource
@@ -88,7 +90,7 @@ type cachedResponse struct {
}
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, freshHC: freshClient(hc), auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
c.rateRemaining.Store(-1)
return c
}
@@ -111,7 +113,9 @@ func sleepCtx(ctx context.Context, d time.Duration) error {
}
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) {
// do performs one authenticated GET, retrying transient failures. fresh sends it on a new connection
// and skips the response cache, so a stale GitHub replica behind a reused connection cannot answer it.
func (c *Client) do(ctx context.Context, repo, target string, fresh bool) ([]byte, string, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
@@ -119,7 +123,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
return nil, "", err
}
}
body, link, retry, err := c.once(ctx, repo, target)
body, link, retry, err := c.once(ctx, repo, target, fresh)
if err == nil {
return body, link, nil
}
@@ -131,7 +135,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
return nil, "", lastErr
}
func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) {
func (c *Client) once(ctx context.Context, repo, target string, fresh bool) (body []byte, link string, retry bool, err error) {
if gerr := c.guard(); gerr != nil {
return nil, "", false, gerr
}
@@ -150,10 +154,15 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
c.mu.Lock()
cached, haveCache := c.cache[target]
c.mu.Unlock()
haveCache = haveCache && !fresh
if haveCache {
req.Header.Set("If-None-Match", cached.etag)
}
resp, err := c.hc.Do(req)
hc := c.hc
if fresh {
hc = c.freshHC
}
resp, err := hc.Do(req)
if err != nil {
return nil, "", ctx.Err() == nil, err
}
@@ -174,7 +183,7 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
return nil, "", resp.StatusCode >= 500, ae
}
link = resp.Header.Get("Link")
if etag := resp.Header.Get("ETag"); etag != "" {
if etag := resp.Header.Get("ETag"); etag != "" && !fresh {
c.mu.Lock()
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
c.mu.Unlock()
@@ -241,7 +250,16 @@ func (c *Client) url(path string, q url.Values) string {
}
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
body, _, err := c.do(ctx, repo, c.url(path, q))
return c.getJSONMode(ctx, repo, path, q, out, false)
}
// getJSONFresh is getJSON on a new connection without the response cache.
func (c *Client) getJSONFresh(ctx context.Context, repo, path string, q url.Values, out any) error {
return c.getJSONMode(ctx, repo, path, q, out, true)
}
func (c *Client) getJSONMode(ctx context.Context, repo, path string, q url.Values, out any, fresh bool) error {
body, _, err := c.do(ctx, repo, c.url(path, q), fresh)
if err != nil {
return err
}
@@ -257,7 +275,7 @@ func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Val
next := c.url(path, q)
var all []T
for next != "" && len(all) < limit {
body, link, err := c.do(ctx, repo, next)
body, link, err := c.do(ctx, repo, next, false)
if err != nil {
return all, err
}
@@ -289,3 +307,18 @@ func nextLink(h string) string {
}
return ""
}
// freshClient copies hc but turns off connection reuse. Custom transports that are not an
// *http.Transport are used as they are.
func freshClient(hc *http.Client) *http.Client {
tr, ok := hc.Transport.(*http.Transport)
if !ok {
if hc.Transport != nil {
return hc
}
tr, _ = http.DefaultTransport.(*http.Transport)
}
clone := tr.Clone()
clone.DisableKeepAlives = true
return &http.Client{Transport: clone, Timeout: hc.Timeout}
}
+49 -12
View File
@@ -262,24 +262,28 @@ func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Wo
if w.State != "active" || len(out) >= maxWorkflows {
continue
}
var runs struct {
WorkflowRuns []rawRun `json:"workflow_runs"`
}
q := url.Values{
"branch": {branch},
"exclude_pull_requests": {"true"},
"per_page": {fmt.Sprint(runsPerWorkflow)},
}
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID)
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
runs, err := c.workflowRuns(ctx, full, w.ID, branch, false)
if err != nil {
if first == nil {
first = fmt.Errorf("%s: %w", w.Name, err)
}
continue
}
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
out = append(out, wf)
wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs)
if !ok {
continue
}
// GitHub sometimes answers a run listing from a lagging replica, and a reused connection
// keeps hitting it. A failure is what raises an alert, so confirm it on a new connection
// and keep the union of both answers.
if wf.LastDone != nil && wf.LastDone.Failed() {
if again, err := c.workflowRuns(ctx, full, w.ID, branch, true); err == nil {
if merged, ok := buildWorkflow(w.Name, w.HTMLURL, mergeRuns(runs, again)); ok {
wf = merged
}
}
}
out = append(out, wf)
}
return out, first
}
@@ -343,3 +347,36 @@ func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*mode
FromTag: true,
}, nil
}
func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch string, fresh bool) ([]rawRun, error) {
var out struct {
WorkflowRuns []rawRun `json:"workflow_runs"`
}
q := url.Values{
"branch": {branch},
"exclude_pull_requests": {"true"},
"per_page": {fmt.Sprint(runsPerWorkflow)},
}
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
get := c.getJSON
if fresh {
get = c.getJSONFresh
}
if err := get(ctx, full, path, q, &out); err != nil {
return nil, err
}
return out.WorkflowRuns, nil
}
// mergeRuns returns the runs of a and b once each, keyed by run ID.
func mergeRuns(a, b []rawRun) []rawRun {
seen := make(map[int64]bool, len(a)+len(b))
out := make([]rawRun, 0, len(a)+len(b))
for _, r := range slices.Concat(a, b) {
if !seen[r.ID] {
seen[r.ID] = true
out = append(out, r)
}
}
return out
}
+94
View File
@@ -459,3 +459,97 @@ func TestAPIErrorMessageParsing(t *testing.T) {
t.Fatalf("len=%d", len(got))
}
}
// staleThenFresh serves an old failing run on the first request and the current runs afterwards,
// like a lagging GitHub replica followed by a healthy one.
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
var calls atomic.Int64
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if n == 1 {
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
return
}
if r.Header.Get("If-None-Match") != "" {
http.Error(w, "fresh requests must not be conditional", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":3,"html_url":"new","event":"schedule","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"},
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
}
return &calls
}
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)
calls := staleThenFresh(f, "/repos/o/r/actions/workflows/7/runs")
wfs, err := c.workflows(context.Background(), "o/r", "main")
if err != nil {
t.Fatal(err)
}
if len(wfs) != 1 || wfs[0].Latest.ID != 3 || wfs[0].LastDone == nil || wfs[0].LastDone.Failed() {
t.Fatalf("the newer success must supersede the stale failure: %+v", wfs)
}
if calls.Load() != 2 {
t.Fatalf("runs requested %d times, want 2", calls.Load())
}
}
func TestHealthyWorkflowIsNotRequestedTwice(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
var calls atomic.Int64
f.routes["/repos/o/r/actions/workflows/7/runs"] = func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"}]}`))
}
if _, err := c.workflows(context.Background(), "o/r", "main"); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("a passing workflow needs one request, got %d", calls.Load())
}
}
func TestConfirmedFailureStaysFailing(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
f.json("/repos/o/r/actions/workflows/7/runs", `{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-02T03:03:00Z"}]}`)
wfs, err := c.workflows(context.Background(), "o/r", "main")
if err != nil || len(wfs) != 1 || wfs[0].LastDone == nil || !wfs[0].LastDone.Failed() {
t.Fatalf("a failure that both answers agree on must stay: %+v %v", wfs, err)
}
}
func TestMergeRunsKeepsEachRunOnce(t *testing.T) {
got := mergeRuns([]rawRun{{ID: 1}, {ID: 2}}, []rawRun{{ID: 2}, {ID: 3}})
if len(got) != 3 || got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 {
t.Fatalf("%+v", got)
}
}
func TestFreshClientDisablesConnectionReuse(t *testing.T) {
base := &http.Client{Transport: &http.Transport{}, Timeout: 5 * time.Second}
fc := freshClient(base)
tr, ok := fc.Transport.(*http.Transport)
if !ok || !tr.DisableKeepAlives || fc.Timeout != 5*time.Second {
t.Fatalf("fresh client: %+v", fc)
}
if base.Transport.(*http.Transport).DisableKeepAlives {
t.Fatal("the original client must keep its connections")
}
custom := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("x") })}
if freshClient(custom) != custom {
t.Fatal("non-standard transports are used as they are")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+8
View File
@@ -16,6 +16,7 @@ type apiState struct {
Alerts []apiAlert `json:"alerts"`
Thresholds apiThresholds `json:"thresholds"`
Counts apiCounts `json:"counts"`
Scan apiScan `json:"scan"`
IntervalSeconds int64 `json:"intervalSeconds"`
Loaded bool `json:"loaded"`
Refreshing bool `json:"refreshing"`
@@ -30,6 +31,12 @@ type apiThresholds struct {
WorkflowDormant int64 `json:"workflowDormantSeconds"`
}
// apiScan reports how far the running scan has got, for the loading screen.
type apiScan struct {
Done int `json:"done"`
Total int `json:"total"`
}
type apiCounts struct {
Crit int `json:"crit"`
Warn int `json:"warn"`
@@ -98,6 +105,7 @@ func buildState(v collector.View, manualRefresh bool) apiState {
Refreshing: v.Refreshing,
ManualRefresh: manualRefresh,
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
Scan: apiScan{Done: v.ScanDone, Total: v.ScanTotal},
Thresholds: apiThresholds{
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
+5 -1
View File
@@ -45,7 +45,7 @@ func testView() collector.View {
}
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
return collector.View{
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1,
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, ScanDone: 5, ScanTotal: 9,
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
Repos: []model.Repo{repo, bare},
Alerts: []model.Alert{
@@ -125,6 +125,7 @@ func TestStateJSONContract(t *testing.T) {
Repos []map[string]any
Alerts []map[string]any
Counts struct{ Crit, Warn int }
Scan struct{ Done, Total int }
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
IntervalSeconds int64
Loaded bool
@@ -133,6 +134,9 @@ func TestStateJSONContract(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Scan.Done != 5 || got.Scan.Total != 9 {
t.Fatalf("scan: %+v", got.Scan)
}
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
t.Fatalf("top level: %+v", got)
}
+2 -1
View File
@@ -68,9 +68,10 @@ describe('App', () => {
})
it('waits while the server has not finished its first refresh', async () => {
const w = mountWith(state({ loaded: false }))
const w = mountWith(state({ loaded: false, scan: { done: 4, total: 10 } }))
await flushPromises()
expect(w.find('[data-test=waiting]').exists()).toBe(true)
expect(w.get('[data-test=loading-detail]').text()).toContain('4 of 10 scanned')
expect(w.find('[data-test=headline]').exists()).toBe(false)
})
+19 -13
View File
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AttentionList from '@/components/AttentionList.vue'
import LoadingScreen from '@/components/LoadingScreen.vue'
import FleetOverview from '@/components/FleetOverview.vue'
import ProjectList from '@/components/ProjectList.vue'
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
@@ -26,9 +27,15 @@ const {
visibleRepos,
activeFilters,
repoCounts,
rowCounts,
facts,
} = storeToRefs(store)
const list = ref<InstanceType<typeof AttentionList> | null>(null)
const progress = computed(() =>
state.value && scanning.value && state.value.scan.total > 0
? `${state.value.scan.done}/${state.value.scan.total}`
: '',
)
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
let timers: number[] = []
@@ -64,13 +71,14 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="mx-auto max-w-7xl px-5 py-6 sm:px-8">
<main class="w-full px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
<AppHeader
:last-refresh="state?.lastRefresh ?? ''"
:now-ms="nowMs"
:interval-seconds="state?.intervalSeconds ?? 0"
:loaded="state?.loaded ?? false"
:scanning="scanning"
:progress="progress"
:manual-refresh="state?.manualRefresh ?? false"
@rescan="store.rescan()"
/>
@@ -83,19 +91,17 @@ onBeforeUnmount(() => {
{{ notice }}
</p>
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
<p
v-else-if="!state.loaded"
class="text-muted-foreground mt-16 text-3xl font-semibold"
data-test="waiting"
>
Fetching data from GitHub
</p>
<LoadingScreen
v-if="!state || !state.loaded"
:done="state?.scan.done ?? 0"
:total="state?.scan.total ?? 0"
:connecting="!state"
/>
<template v-else>
<div class="mt-12">
<div class="mt-8 sm:mt-12">
<FleetOverview
:crit="state.counts.crit"
:warn="state.counts.warn"
:crit="rowCounts.crit"
:warn="rowCounts.warn"
:block-count="blocks.length"
:repos="state.repos"
:counts="repoCounts"
@@ -106,7 +112,7 @@ onBeforeUnmount(() => {
/>
</div>
<div
class="mt-14 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,1fr)_23rem]"
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
>
<AttentionList
ref="list"
+12 -4
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
intervalSeconds: number
loaded: boolean
scanning: boolean
progress: string
manualRefresh: boolean
}>()
defineEmits<{ rescan: [] }>()
@@ -28,7 +29,7 @@ const nextIn = computed(() => {
</script>
<template>
<header class="flex items-center justify-between gap-4">
<header class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-3">
<span
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
@@ -37,8 +38,12 @@ const nextIn = computed(() => {
</span>
<span class="text-lg font-semibold tracking-tight">ghtracker</span>
</div>
<div class="text-muted-foreground flex items-center gap-5 text-sm">
<span v-if="updated" class="hidden sm:inline" data-test="updated">
<div class="text-muted-foreground flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
<span
v-if="updated"
class="order-last w-full text-xs sm:order-none sm:w-auto sm:text-sm"
data-test="updated"
>
Scanned {{ updated }} ago
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
</span>
@@ -52,7 +57,10 @@ const nextIn = computed(() => {
@click="$emit('rescan')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
<span class="hidden sm:inline">{{
scanning ? `Scanning ${progress}`.trim() : 'Rescan repositories'
}}</span>
<span class="sm:hidden">{{ scanning ? progress || 'Scanning…' : 'Rescan' }}</span>
</Button>
</div>
</header>
+20 -10
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import FilterBar from '@/components/FilterBar.vue'
import RepoGroup from '@/components/RepoGroup.vue'
import { useColumns } from '@/composables/useColumns'
import { balanceColumns } from '@/lib/layout'
import type { Facets, Filters } from '@/lib/filters'
import type { Block } from '@/lib/types'
defineProps<{
const props = defineProps<{
blocks: Block[]
nowMs: number
critSeconds: number
@@ -18,6 +20,12 @@ defineProps<{
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const columnCount = useColumns()
// A group's height is its header plus its rows, so rows+2 approximates it.
const columns = computed(() =>
balanceColumns(props.blocks, columnCount.value, (b) => b.rows.length + 2),
)
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
defineExpose({ focusSearch: () => bar.value?.focus() })
</script>
@@ -43,14 +51,16 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
Nothing matches the current filters.
</div>
<div v-else class="flex flex-col gap-4">
<RepoGroup
v-for="block in blocks"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
<div v-else class="flex items-start gap-4" data-test="columns">
<div v-for="(column, i) in columns" :key="i" class="flex min-w-0 flex-1 flex-col gap-4">
<RepoGroup
v-for="block in column"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</div>
</div>
</section>
</template>
+7 -2
View File
@@ -12,7 +12,10 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
</script>
<template>
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row">
<li
class="grid grid-cols-[2rem_minmax(0,1fr)] items-start gap-x-3 gap-y-1 py-3 sm:grid-cols-[2rem_minmax(0,1fr)_auto]"
data-test="row"
>
<span
class="flex size-8 items-center justify-center rounded-md"
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
@@ -31,7 +34,9 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
{{ shorten(row.detail, 120) }}
</p>
</div>
<div class="flex flex-col items-end gap-1.5 pt-0.5">
<div
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
>
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
</span>
+4 -1
View File
@@ -49,7 +49,7 @@ function onOwners(value: unknown): void {
<template>
<div class="flex flex-col gap-3" data-test="filter-bar">
<div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-56 flex-1">
<div class="relative min-w-0 basis-full sm:min-w-56 sm:flex-1 sm:basis-0">
<FontAwesomeIcon
:icon="faMagnifyingGlass"
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
@@ -75,6 +75,7 @@ function onOwners(value: unknown): void {
</div>
<ToggleGroup
type="single"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline"
size="sm"
:model-value="filters.severity"
@@ -96,6 +97,7 @@ function onOwners(value: unknown): void {
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleGroup
type="multiple"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline"
size="sm"
:model-value="filters.kinds"
@@ -117,6 +119,7 @@ function onOwners(value: unknown): void {
<ToggleGroup
v-if="owners.length > 1"
type="multiple"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline"
size="sm"
:model-value="filters.owners"
+41 -34
View File
@@ -56,10 +56,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
</script>
<template>
<section
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]"
aria-label="Fleet status"
>
<section class="flex flex-col gap-7 sm:gap-8" aria-label="Fleet status">
<div>
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
<template v-if="blockCount > 0"
@@ -68,50 +65,60 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
<template v-else>All {{ repos.length }} projects are healthy</template>
</p>
<h1
class="flex flex-wrap items-baseline gap-x-10 gap-y-1 font-semibold tracking-tight"
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 font-semibold tracking-tight sm:gap-x-10"
data-test="headline"
>
<template v-if="crit > 0 || warn > 0">
<span v-if="crit > 0" class="text-crit">
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span>
<span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ crit }}</span>
<span class="ml-2 text-xl font-medium">critical</span>
</span>
<span v-if="warn > 0" class="text-warn">
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span>
<span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ warn }}</span>
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
</span>
</template>
<span v-else class="text-ok text-7xl leading-none">All clear</span>
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
</h1>
<div class="mt-8">
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<span class="ml-auto">Select a segment to focus a project</span>
</p>
</div>
</div>
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
<dt class="flex-1">{{ row.label }}</dt>
<div>
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<span class="sm:ml-auto">Tap a segment to focus a project</span>
</p>
</div>
<dl
class="border-border grid grid-cols-2 gap-x-6 border-t sm:grid-cols-3 lg:grid-cols-5"
data-test="facts"
>
<div
v-for="row in rows"
:key="row.label"
class="border-border border-b py-3 last:col-span-2 sm:last:col-span-1 lg:border-b-0"
>
<dt class="text-muted-foreground flex items-center gap-2 text-xs">
<FontAwesomeIcon :icon="row.icon" class="w-3.5" aria-hidden="true" />
{{ row.label }}
</dt>
<dd
class="font-mono text-base tabular-nums"
class="mt-1 font-mono text-3xl tabular-nums"
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
>
{{ row.value }}
+7 -2
View File
@@ -24,14 +24,19 @@ function summary(c: RepoCounts | undefined): string {
<template>
<TooltipProvider :delay-duration="80">
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip">
<div
class="grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1"
role="group"
aria-label="Project health"
data-test="strip"
>
<Tooltip v-for="repo in repos" :key="repo.fullName">
<TooltipTrigger as-child>
<button
type="button"
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
:aria-pressed="focused === repo.fullName"
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1"
class="h-9 min-w-0 rounded-[3px] sm:h-11 transition-colors focus-visible:outline-offset-1"
:class="[
SEGMENT[repo.health],
focused === repo.fullName
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import { Progress } from '@/components/ui/progress'
const props = defineProps<{ done: number; total: number; connecting: boolean }>()
// Static class names so Tailwind can see every delay; cells cycle through them for a wave effect.
const DELAYS = [
'[animation-delay:0ms]',
'[animation-delay:140ms]',
'[animation-delay:280ms]',
'[animation-delay:420ms]',
'[animation-delay:560ms]',
'[animation-delay:700ms]',
]
const delay = (i: number) => DELAYS[i % DELAYS.length]
const MIN_CELLS = 12
const MAX_CELLS = 63
const cells = computed(() => Math.min(MAX_CELLS, Math.max(MIN_CELLS, props.total)))
const percent = computed(() => (props.total > 0 ? Math.round((props.done / props.total) * 100) : 0))
const known = computed(() => !props.connecting && props.total > 0)
</script>
<template>
<section role="status" aria-live="polite" class="mt-10 sm:mt-16" data-test="waiting">
<div class="flex items-center gap-4">
<span
class="border-border bg-surface text-link relative flex size-12 items-center justify-center rounded-lg border"
>
<span
class="border-link/50 absolute inset-0 rounded-lg border motion-safe:animate-ping"
></span>
<FontAwesomeIcon :icon="faSatelliteDish" class="text-xl" aria-hidden="true" />
</span>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl" data-test="loading-title">
{{ connecting ? 'Connecting' : 'Scanning repositories' }}
</h1>
<p class="text-muted-foreground mt-0.5 text-sm" data-test="loading-detail">
<template v-if="known">{{ done }} of {{ total }} scanned</template>
<template v-else-if="connecting">Waiting for the server</template>
<template v-else>Contacting GitHub</template>
<span class="hidden sm:inline"> · the first scan can take a minute or two</span>
</p>
</div>
</div>
<div class="mt-7 max-w-2xl">
<Progress v-if="known" :model-value="percent" class="h-1.5" aria-label="Scan progress" />
<div v-else class="bg-secondary h-1.5 w-full overflow-hidden rounded-full">
<div class="bg-primary h-full w-1/3 rounded-full motion-safe:animate-pulse"></div>
</div>
</div>
<div class="mt-10" aria-hidden="true">
<div class="flex items-baseline gap-8">
<div
class="bg-secondary h-14 w-28 rounded-md motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
></div>
<div
class="bg-secondary h-14 w-28 rounded-md [animation-delay:200ms] motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
></div>
</div>
<div class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1">
<div
v-for="i in cells"
:key="i"
class="bg-secondary h-9 rounded-[3px] motion-safe:animate-pulse sm:h-11"
:class="delay(i)"
></div>
</div>
<div
class="border-border mt-7 grid grid-cols-2 gap-x-6 border-t pt-3 sm:grid-cols-3 lg:grid-cols-5"
>
<div v-for="i in 5" :key="i" class="py-2">
<div
class="bg-secondary h-3 w-24 rounded motion-safe:animate-pulse"
:class="delay(i)"
></div>
<div
class="bg-secondary mt-2 h-7 w-12 rounded motion-safe:animate-pulse"
:class="delay(i + 1)"
></div>
</div>
</div>
</div>
<div
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]"
aria-hidden="true"
>
<div class="flex flex-col gap-4">
<div v-for="g in 3" :key="g" class="border-border bg-surface rounded-lg border p-4">
<div
class="bg-secondary h-5 w-48 rounded motion-safe:animate-pulse"
:class="delay(g)"
></div>
<div v-for="r in 2" :key="r" class="mt-4 flex items-center gap-3">
<div
class="bg-secondary size-8 shrink-0 rounded-md motion-safe:animate-pulse"
:class="delay(g + r)"
></div>
<div class="flex-1 space-y-2">
<div
class="bg-secondary h-4 w-2/3 rounded motion-safe:animate-pulse"
:class="delay(g + r)"
></div>
<div
class="bg-secondary h-3 w-1/3 rounded motion-safe:animate-pulse"
:class="delay(g + r + 1)"
></div>
</div>
</div>
</div>
</div>
<div class="border-border bg-surface hidden rounded-lg border p-3 lg:block">
<div v-for="i in 6" :key="i" class="py-2.5">
<div
class="bg-secondary h-4 w-3/4 rounded motion-safe:animate-pulse"
:class="delay(i)"
></div>
<div
class="bg-secondary mt-2 h-3 w-1/2 rounded motion-safe:animate-pulse"
:class="delay(i + 1)"
></div>
</div>
</div>
</div>
</section>
</template>
+66 -3
View File
@@ -1,12 +1,63 @@
<script setup lang="ts">
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
defineEmits<{ select: [fullName: string] }>()
/** Rows rendered at first and added each time the list is scrolled near its end. */
const PAGE_SIZE = 20
const shown = ref(PAGE_SIZE)
const visible = computed(() => props.repos.slice(0, shown.value))
const hasMore = computed(() => shown.value < props.repos.length)
const scroller = ref<HTMLElement | null>(null)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
function loadMore(): void {
if (hasMore.value) shown.value += PAGE_SIZE
}
// A new search or filter starts the list over from the top.
watch(
() => props.repos,
() => {
shown.value = PAGE_SIZE
if (scroller.value) scroller.value.scrollTop = 0
},
)
// Observe the end marker; a fresh marker element is rendered while more rows remain.
watch(
sentinel,
(el, prev) => {
if (prev) observer?.unobserve(prev)
if (el) observer?.observe(el)
},
{ flush: 'post' },
)
onMounted(async () => {
await nextTick()
if (typeof IntersectionObserver === 'undefined') {
shown.value = props.repos.length
return
}
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadMore()
},
{ root: scroller.value, rootMargin: '200px' },
)
if (sentinel.value) observer.observe(sentinel.value)
})
onBeforeUnmount(() => observer?.disconnect())
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</script>
@@ -26,9 +77,13 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
>
No projects match the search.
</p>
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border">
<ul
v-else
ref="scroller"
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] lg:max-h-[calc(100vh-7rem)]"
>
<li
v-for="repo in repos"
v-for="repo in visible"
:key="repo.fullName"
class="px-3 py-2.5"
:class="focused === repo.fullName ? 'bg-accent' : ''"
@@ -86,6 +141,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</span>
</div>
</li>
<li
v-if="hasMore"
ref="sentinel"
class="text-muted-foreground px-3 py-3 text-center text-xs"
data-test="more"
>
Showing {{ visible.length }} of {{ repos.length }}
</li>
</ul>
</section>
</template>
+147 -3
View File
@@ -1,9 +1,10 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgeMeter from './AgeMeter.vue'
import AppHeader from './AppHeader.vue'
import AttentionList from './AttentionList.vue'
import FilterBar from './FilterBar.vue'
import LoadingScreen from './LoadingScreen.vue'
import AttentionRow from './AttentionRow.vue'
import FleetOverview from './FleetOverview.vue'
import ProjectList from './ProjectList.vue'
@@ -24,6 +25,7 @@ describe('AppHeader', () => {
intervalSeconds: 21600,
loaded: true,
scanning: false,
progress: '',
}
it('shows when data was fetched and when the next refresh is due', () => {
@@ -39,12 +41,16 @@ describe('AppHeader', () => {
it('emits rescan and shows progress while a scan runs', async () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories')
expect(w.get('[data-test=rescan]').text()).toContain('Rescan repositories')
await w.get('[data-test=rescan]').trigger('click')
expect(w.emitted('rescan')).toHaveLength(1)
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
expect(busy.get('[data-test=rescan]').text()).toBe('Rescanning…')
expect(busy.get('[data-test=rescan]').text()).toContain('Scanning')
const counted = mount(AppHeader, {
props: { ...base, manualRefresh: true, scanning: true, progress: '23/63' },
})
expect(counted.get('[data-test=rescan]').text()).toContain('Scanning 23/63')
})
})
@@ -304,3 +310,141 @@ describe('ProjectList', () => {
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
})
})
describe('AttentionList columns', () => {
const blocks = ['a/one', 'b/two', 'c/three'].map((fullName) => ({
repo: repo({ fullName, health: 'crit' }),
rows: buildBlocks(mixedState())[0].rows,
}))
const props = {
blocks,
nowMs,
critSeconds: CRIT_SECONDS,
facets: facetCounts(blocks, emptyFilters(), ['a', 'b', 'c']),
owners: ['a'],
active: 0,
filters: emptyFilters(),
}
afterEach(() => vi.unstubAllGlobals())
it('uses one column on narrow screens', () => {
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
}))
const w = mount(AttentionList, { props })
expect(w.findAll('[data-test=columns] > div')).toHaveLength(1)
})
it('balances groups over two columns on very wide screens', async () => {
vi.stubGlobal('matchMedia', () => ({
matches: true,
addEventListener: () => {},
removeEventListener: () => {},
}))
const w = mount(AttentionList, { props })
await w.vm.$nextTick()
const cols = w.findAll('[data-test=columns] > div')
expect(cols).toHaveLength(2)
expect(cols[0].findAll('[data-test=repo-block]')).toHaveLength(2)
expect(cols[1].findAll('[data-test=repo-block]')).toHaveLength(1)
})
})
describe('ProjectList lazy loading', () => {
type Callback = (entries: { isIntersecting: boolean }[]) => void
let callback: Callback = () => {}
function stubObserver() {
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: Callback) {
callback = cb
}
observe() {}
unobserve() {}
disconnect() {}
},
)
}
const many = (n: number) => Array.from({ length: n }, (_, i) => repo({ fullName: `o/repo-${i}` }))
afterEach(() => vi.unstubAllGlobals())
it('renders a first page, then more each time the end marker scrolls into view', async () => {
stubObserver()
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(20)
expect(w.get('[data-test=more]').text()).toBe('Showing 20 of 55')
callback([{ isIntersecting: true }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(40)
callback([{ isIntersecting: false }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(40)
callback([{ isIntersecting: true }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(55)
expect(w.find('[data-test=more]').exists()).toBe(false)
})
it('starts over from the first page when the list changes', async () => {
stubObserver()
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
await w.vm.$nextTick()
callback([{ isIntersecting: true }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(40)
await (w as unknown as { setProps(p: object): Promise<void> }).setProps({ repos: many(30) })
expect(w.findAll('[data-test=project]')).toHaveLength(20)
})
it('renders everything when the browser has no IntersectionObserver', async () => {
vi.stubGlobal('IntersectionObserver', undefined)
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
await w.vm.$nextTick()
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(55)
})
})
describe('LoadingScreen', () => {
it('shows scan progress with a count once the server reports a total', () => {
const w = mount(LoadingScreen, { props: { done: 23, total: 63, connecting: false } })
expect(w.get('[data-test=loading-title]').text()).toBe('Scanning repositories')
expect(w.get('[data-test=loading-detail]').text()).toContain('23 of 63 scanned')
expect(w.get('[role=progressbar]').attributes('aria-valuenow')).toBe('37')
})
it('draws one skeleton cell per repo, within sensible limits', () => {
const cells = (total: number) =>
mount(LoadingScreen, { props: { done: 0, total, connecting: false } }).findAll(
'.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(1\\.5rem\\,1fr\\)\\)\\] > div',
).length
expect(cells(5)).toBe(12)
expect(cells(30)).toBe(30)
expect(cells(500)).toBe(63)
})
it('says it is connecting before the first response', () => {
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: true } })
expect(w.get('[data-test=loading-title]').text()).toBe('Connecting')
expect(w.find('[role=progressbar]').exists()).toBe(false)
})
it('uses an indeterminate bar while the total is not known yet', () => {
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: false } })
expect(w.get('[data-test=loading-detail]').text()).toContain('Contacting GitHub')
expect(w.find('[role=progressbar]').exists()).toBe(false)
})
it('is announced politely to screen readers', () => {
const w = mount(LoadingScreen, { props: { done: 1, total: 2, connecting: false } })
expect(w.attributes('role')).toBe('status')
expect(w.attributes('aria-live')).toBe('polite')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> {
const columns = ref(1)
let query: MediaQueryList | null = null
const update = () => {
columns.value = query?.matches ? 2 : 1
}
onMounted(() => {
if (typeof window.matchMedia !== 'function') return
query = window.matchMedia(minWidth)
update()
query.addEventListener('change', update)
})
onBeforeUnmount(() => query?.removeEventListener('change', update))
return columns
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { balanceColumns } from './layout'
const weight = (n: number) => n
describe('balanceColumns', () => {
it('returns one column holding everything in order', () => {
expect(balanceColumns([3, 1, 2], 1, weight)).toEqual([[3, 1, 2]])
})
it('puts each item in the currently shortest column', () => {
// heights after each step: [5,0] -> [5,2] -> [5,5] -> [5,6]... ties go to the first column
expect(balanceColumns([5, 2, 3, 1], 2, weight)).toEqual([
[5, 1],
[2, 3],
])
})
it('keeps the heaviest-first reading order within each column', () => {
const [left, right] = balanceColumns([9, 8, 7, 6, 5], 2, weight)
expect(left).toEqual([9, 6, 5])
expect(right).toEqual([8, 7])
})
it('handles empty input and clamps invalid column counts', () => {
expect(balanceColumns([], 2, weight)).toEqual([[], []])
expect(balanceColumns([1, 2], 0, weight)).toEqual([[1, 2]])
expect(balanceColumns([1, 2], 2.9, weight)).toHaveLength(2)
})
})
+16
View File
@@ -0,0 +1,16 @@
/**
* Splits items into columns so the columns end at about the same height. Each item goes to the
* currently shortest column, which keeps the original order readable (worst items first).
*/
export function balanceColumns<T>(items: T[], columns: number, weight: (item: T) => number): T[][] {
const count = Math.max(1, Math.floor(columns))
const out: T[][] = Array.from({ length: count }, () => [])
const heights = new Array<number>(count).fill(0)
for (const item of items) {
let target = 0
for (let i = 1; i < count; i++) if (heights[i] < heights[target]) target = i
out[target].push(item)
heights[target] += weight(item)
}
return out
}
+1
View File
@@ -49,6 +49,7 @@ export interface TrackerState {
loaded: boolean
refreshing: boolean
manualRefresh: boolean
scan: { done: number; total: number }
counts: { crit: number; warn: number }
thresholds: Thresholds
repos: Repo[]
+9
View File
@@ -134,6 +134,15 @@ describe('tracker store', () => {
expect(store.owners).toEqual(['o'])
})
it('counts rows by severity so the headline matches the lists', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
// three alerts, but the two on the same pull request merge into one row
expect(store.rowCounts).toEqual({ crit: 2, warn: 0 })
expect(store.facets.severity.all).toBe(store.rowCounts.crit + store.rowCounts.warn)
})
it('searches projects by name and clears every filter at once', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
+7
View File
@@ -122,6 +122,12 @@ export const useTrackerStore = defineStore('tracker', () => {
)
})
const activeFilters = computed(() => activeFilterCount(filters.value))
/** Rows by severity, so the headline matches the lists below it (alerts on one item merge into one row). */
const rowCounts = computed(() => {
const out = { crit: 0, warn: 0 }
for (const b of blocks.value) for (const r of b.rows) out[r.severity] += 1
return out
})
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
@@ -189,6 +195,7 @@ export const useTrackerStore = defineStore('tracker', () => {
activeFilters,
visibleBlocks,
repoCounts,
rowCounts,
facts,
toggleRepo,
clearFilters,
+1
View File
@@ -49,6 +49,7 @@ export function state(over: Partial<TrackerState> = {}): TrackerState {
loaded: true,
refreshing: false,
manualRefresh: false,
scan: { done: 0, total: 0 },
counts: { crit: 0, warn: 0 },
thresholds: {
prWarnSeconds: 172800,