Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b150214
feat(status): adds GitHub status service layer
wgordon17 Aug 7, 2026
1dab98a
chore(security): allows www.githubstatus.com in CSP connect-src
wgordon17 Aug 7, 2026
4e6affd
feat(status): checks GitHub status on every full-poll cycle
wgordon17 Aug 7, 2026
983e7c8
feat(status): adds GitHubStatusBadge component
wgordon17 Aug 7, 2026
e7209f0
feat(status): renders GitHubStatusBadge in Header
wgordon17 Aug 7, 2026
087ae11
fix(status): floors severity and gates dismissal on repeat failures
wgordon17 Aug 7, 2026
d2af869
test(github-status): adds severity popover and focus coverage
wgordon17 Aug 7, 2026
2227d9c
docs: documents GitHub status tracking in the user guide
wgordon17 Aug 7, 2026
3bfb1c6
docs(contributing): corrects commit message mood convention
wgordon17 Aug 7, 2026
a16ef50
fix(status): closes schema-drift path around failure gate
wgordon17 Aug 7, 2026
e2c2efd
test(status): covers untested assumptions in github-status service
wgordon17 Aug 8, 2026
782f327
fix(status): resets stale tooltip hover state after popover dismissal
wgordon17 Aug 8, 2026
d93bfa9
refactor(status): simplifies GitHubStatusBadge Show narrowing
wgordon17 Aug 8, 2026
4ce4bed
docs(status): corrects inaccurate "pure" comment on detectNewItems
wgordon17 Aug 8, 2026
a04e2f7
chore(status): dedupes vitest smoke config and sharpens its diagnostic
wgordon17 Aug 8, 2026
2d8363f
ci(status): adds weekly smoke test workflow
wgordon17 Aug 8, 2026
56b4e87
docs(contributing): documents the test:status-smoke script
wgordon17 Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/status-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Status Smoke Test
on:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
status-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.18.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:status-smoke
13 changes: 7 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ Fine-grained PATs need Actions (read), Contents (read), Issues (read), and Pull
## Running checks

```bash
pnpm test # unit tests (Vitest — root + mcp/)
pnpm test:e2e # Playwright E2E tests (chromium)
pnpm run typecheck # TypeScript validation (root + mcp/)
pnpm run screenshot # Capture dashboard screenshot (saves to docs/)
pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN)
pnpm test # unit tests (Vitest — root + mcp/)
pnpm test:e2e # Playwright E2E tests (chromium)
pnpm test:status-smoke # Live-network check that githubstatus.com still reports the tracked component names (also runs on a weekly schedule in CI)
pnpm run typecheck # TypeScript validation (root + mcp/)
pnpm run screenshot # Capture dashboard screenshot (saves to docs/)
pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN)
```

To test MCP tools interactively, use the MCP Inspector:
Expand Down Expand Up @@ -98,7 +99,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/):
type(scope): description
```

Scope is optional. Use imperative mood: "add feature", not "adds feature" or "added feature".
Scope is optional. Use present-indicative mood: "adds feature", not "add feature" or "added feature".

## Releasing the MCP server

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ A second, faster poll loop (default 30s, configurable 10–120s) targets only in

Browser notifications for new issues, PRs, and failed runs. Per-type toggles in settings. Notification permission requested on first enable. New items are detected via the Events API polling loop and full refresh cycles.

### GitHub Status Badge

A status dot in the header reflects GitHub's own reported status for the services this dashboard depends on (Actions, API Requests, Git Operations, Issues, Pull Requests). Click it for incident details and a link to githubstatus.com. Toast and drawer notifications fire on incident start and resolution.

### Repo Pinning and Reordering

Lock repos to the top of each tab's list so they don't shift around as activity changes. Drag-to-reorder within the locked set. Lock controls appear on hover on desktop, always visible on mobile.
Expand Down
15 changes: 15 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi
- [Personal Summary Strip](#personal-summary-strip)
- [Repo Grouping and Expand/Collapse](#repo-grouping-and-expandcollapse)
- [Scope Filter](#scope-filter)
- [GitHub Status Badge](#github-status-badge)
- [Issues Tab](#issues-tab)
- [Filters](#issues-filters)
- [Dependency Dashboard Toggle](#dependency-dashboard-toggle)
Expand Down Expand Up @@ -148,6 +149,12 @@ The **Scope** filter chip appears on the Issues and Pull Requests tabs when you

The scope filter is hidden (and always set to "Involves me") when you have no tracked users and no monitor-all repos, because in that configuration all fetched data already involves you.

### GitHub Status Badge

A small status dot appears in the header, next to your avatar. It reflects GitHub's own reported status for the services this dashboard depends on — Actions, API Requests, Git Operations, Issues, and Pull Requests. Green means all tracked services are operational; yellow, orange, or red indicate minor, major, or critical disruption respectively.

Click the badge to see details on any active incident, including affected components and the latest status update, plus a link to [githubstatus.com](https://www.githubstatus.com). Other GitHub services (Copilot, Codespaces, Pages, Packages, etc.) are not tracked by this badge.

---

## Issues Tab
Expand Down Expand Up @@ -369,6 +376,10 @@ Setting the interval to **Off** disables automatic polling; manual refresh still

A ±30 second jitter is applied to the refresh interval to avoid synchronized API spikes from multiple browser tabs.

### GitHub Status Checks

Each full refresh also checks GitHub's own status page for outages affecting Actions, API Requests, Git Operations, Issues, or Pull Requests. This is a single lightweight, unauthenticated request and does not count against your GitHub API rate limit. It follows the same schedule as the full refresh — including the same visibility-based pausing — rather than running on its own timer.

### Hot Poll

A second, faster poll loop runs alongside the full refresh specifically for in-flight items. It targets:
Expand Down Expand Up @@ -410,6 +421,10 @@ When you return to a tab that has been hidden for more than 2 minutes, a catch-u

The bell icon in the header opens the notification drawer, which shows API errors, rate limit warnings, and other system messages. Notifications are dismissed automatically when the underlying condition clears on the next poll cycle.

### GitHub Outage Notifications

When a GitHub outage affecting a tracked service starts, a toast appears and an entry is added to the notification drawer. A second notification confirms it once the outage resolves. These always appear via the in-app toast and drawer — they are not part of the **Browser Push Notifications** toggles below and cannot be disabled.

### Browser Push Notifications

Browser push notifications are disabled by default. To enable them:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"typecheck": "tsc --build src/shared/tsconfig.json && tsc --noEmit && tsc --build mcp/tsconfig.json",
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test",
"test:waf": "bash scripts/waf-smoke-test.sh",
"test:status-smoke": "vitest run --config vitest.smoke.config.ts",
"screenshot": "pnpm exec playwright test --config playwright.config.screenshot.ts",
"mcp:serve": "pnpm --filter github-tracker-mcp dev",
"validate:deploy": "bash scripts/validate-deploy.sh"
Expand Down
2 changes: 1 addition & 1 deletion public/_headers
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com https://www.githubstatus.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/api/csp-report"
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getUnreadCount, markAllAsRead } from "../../lib/errors";
import NotificationDrawer from "../shared/NotificationDrawer";
import ToastContainer from "../shared/ToastContainer";
import { Tooltip } from "../shared/Tooltip";
import GitHubStatusBadge from "../shared/GitHubStatusBadge";

export default function Header() {
const navigate = useNavigate();
Expand Down Expand Up @@ -56,6 +57,8 @@ export default function Header() {
)}
</Show>

<GitHubStatusBadge />

<Tooltip content="Settings">
<a
href="/settings"
Expand Down
71 changes: 71 additions & 0 deletions src/app/components/shared/GitHubStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Show, For, createMemo, createSignal } from "solid-js";
import { Popover } from "@kobalte/core/popover";
import { Tooltip } from "./Tooltip";
import { getGitHubStatus, type GitHubStatusSeverity } from "../../services/github-status";

const SEVERITY_CONFIG: Record<GitHubStatusSeverity, { bg: string; label: string; pulse: boolean }> = {
none: { bg: "bg-success", label: "All systems operational", pulse: false },
minor: { bg: "bg-warning", label: "Minor GitHub service disruption", pulse: false },
major: { bg: "bg-orange-500", label: "Major GitHub service outage", pulse: true },
critical: { bg: "bg-red-500", label: "Critical GitHub service outage", pulse: true },
};

export default function GitHubStatusBadge() {
const status = createMemo(() => getGitHubStatus());
const cfg = createMemo(() => {
const s = status();
return s !== null
? SEVERITY_CONFIG[s.severity]
: { bg: "bg-base-content/20", label: "Checking GitHub status…", pulse: false };
});
const statusSummaryRow = () => (
<div class="flex items-center gap-2">
<span class={`inline-flex rounded-full w-2 h-2 ${cfg().bg}`} />
<span>{cfg().label}</span>
</div>
);
const incidentList = createMemo(() => {
const s = status();
return s && s.incidents.length > 0 ? s.incidents : null;
});
const [popoverOpen, setPopoverOpen] = createSignal(false);

return (
<Popover placement="bottom-end" onOpenChange={setPopoverOpen}>
<Tooltip content={cfg().label} placement="bottom" forceClosed={popoverOpen()}>
<Popover.Trigger as="button" type="button" class="btn btn-ghost btn-sm shrink-0" aria-label={cfg().label}>
<span class="relative inline-flex items-center justify-center w-3 h-3">
<Show when={cfg().pulse}>
<span class={`absolute inline-flex h-full w-full rounded-full ${cfg().bg} animate-slow-pulse`} />
</Show>
<span class={`relative inline-flex rounded-full w-2 h-2 ${cfg().bg}`} />
</span>
</Popover.Trigger>
</Tooltip>
<Popover.Portal>
<Popover.Content aria-label="GitHub status" class="bg-base-100 border border-base-300 rounded-lg shadow-lg z-50 p-3 w-72 text-sm">
<Show when={incidentList()} fallback={statusSummaryRow()}>
{(list) => (
<ul class="flex flex-col gap-3">
<For each={list()}>
{(incident) => (
<li>
<div class="font-medium">{incident.name}</div>
<div class="text-xs text-base-content/60">Affects: {incident.affectedComponents.join(", ")}</div>
<Show when={incident.latestUpdateBody}>
<p class="mt-1 text-xs whitespace-pre-line">{incident.latestUpdateBody}</p>
</Show>
</li>
)}
</For>
</ul>
)}
</Show>
<a href="https://www.githubstatus.com" target="_blank" rel="noopener noreferrer" class="link link-hover text-xs mt-2 inline-block">
View githubstatus.com
</a>
</Popover.Content>
</Popover.Portal>
</Popover>
);
}
22 changes: 20 additions & 2 deletions src/app/components/shared/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createMemo, createSignal, onCleanup } from "solid-js";
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip";
import type { JSX } from "solid-js";

Expand All @@ -12,13 +12,14 @@ interface TooltipProps {
focusable?: boolean;
class?: string;
contentClass?: string;
forceClosed?: boolean;
children: JSX.Element;
}

export function Tooltip(props: TooltipProps) {
const [isHovered, setIsHovered] = createSignal(false);
const [isFocused, setIsFocused] = createSignal(false);
const open = createMemo(() => isHovered() || isFocused());
const open = createMemo(() => !props.forceClosed && (isHovered() || isFocused()));

// openDelay is ignored in controlled mode; implement the delay manually
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
Expand All @@ -28,6 +29,23 @@ export function Tooltip(props: TooltipProps) {
clearTimeout(closeTimer);
});

// A consumer (e.g. GitHubStatusBadge) can force this tooltip closed while something else,
// like a Popover, is open. Dismissing that Popover via Escape or an outside click never
// fires a pointerleave/blur on this tooltip's own trigger, so isHovered/isFocused can be
// left stale as `true`. Reset them when forceClosed transitions back to false so the
// tooltip doesn't flash back open on its own — a fresh hover/focus is required to reopen it.
createEffect(on(
() => !!props.forceClosed,
(forceClosed, prevForceClosed) => {
if (prevForceClosed && !forceClosed) {
clearTimeout(hoverTimer);
clearTimeout(closeTimer);
setIsHovered(false);
setIsFocused(false);
}
}
));

return (
<KobalteTooltip
open={open()}
Expand Down
Loading