Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ While the hot poll is active, a subtle shimmer animation appears on affected PR

The hot poll pauses automatically when the browser tab is hidden (since visual feedback has no value in a background tab).

Hover the rate limit display in the dashboard footer to see detailed remaining counts for the Core and GraphQL API pools, plus the reset time.

### Tab Visibility Behavior

When the tab is hidden:
Expand Down Expand Up @@ -379,6 +381,7 @@ Settings are saved automatically to `localStorage` and persist across sessions.
| Default tab | Issues | Tab shown when opening the dashboard fresh (without remembered last tab). |
| Remember last tab | On | Return to the last active tab on revisit. |
| Enable tracked items | Off | Show the Tracked tab for pinning issues and PRs to a personal TODO list. |
| API Usage | — | Displays per-source API call counts, pool labels (Core/GraphQL), and last-called timestamps for the current rate limit window. Counts auto-reset when the rate limit window expires. Use "Reset counts" to clear manually. |

### View State Settings

Expand Down Expand Up @@ -411,6 +414,8 @@ The tracker uses GitHub's GraphQL and REST APIs. Each poll cycle consumes some o

OAuth tokens and classic PATs use the notifications gate (304 shortcut), which significantly reduces per-cycle cost when nothing has changed. Fine-grained PATs do not support this optimization.

For detailed per-source API call counts, see Settings > API Usage.

**PAT vs OAuth: what is the difference?**

OAuth tokens (from "Sign in with GitHub") work across all your organizations and support all features including the notifications background-poll optimization. Classic PATs with the correct scopes (`repo`, `read:org`, `notifications`) behave identically to OAuth.
Expand All @@ -428,4 +433,4 @@ Go to **Settings > Repositories > Manage Repositories**, find the repo, and dese
**How do I sign out or reset everything?**

- **Sign out**: Settings > Data > Sign out. This clears your auth token and returns you to the login page. Your config is preserved.
- **Reset all**: Settings > Data > Reset all. This clears all settings, cache, auth tokens, and reloads the page. All configuration is lost.
- **Reset all**: Settings > Data > Reset all. This clears all settings, cache, auth tokens, API usage data, and reloads the page. All configuration is lost.
30 changes: 24 additions & 6 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from "../../services/poll";
import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
import { pushNotification } from "../../lib/errors";
import { getClient, getGraphqlRateLimit } from "../../services/github";
import { getClient, getGraphqlRateLimit, fetchRateLimitDetails } from "../../services/github";
import { formatCount } from "../../lib/format";
import { setsEqual } from "../../lib/collections";
import { withScrollLock } from "../../lib/scroll";
Expand Down Expand Up @@ -253,6 +253,22 @@ const [_hotCoordinator, _setHotCoordinator] = createSignal<{ destroy: () => void
export default function DashboardPage() {
const [hotPollingPRIds, setHotPollingPRIds] = createSignal<ReadonlySet<number>>(new Set());
const [hotPollingRunIds, setHotPollingRunIds] = createSignal<ReadonlySet<number>>(new Set());
const [rlDetail, setRlDetail] = createSignal<string>("Loading...");

function fetchAndSetRlDetail(): void {
void fetchRateLimitDetails().then((detail) => {
if (!detail) {
setRlDetail("Failed to load");
return;
}
const resetTime = detail.graphql.resetAt.toLocaleTimeString();
setRlDetail(
`Core: ${detail.core.remaining.toLocaleString()}/${detail.core.limit.toLocaleString()} remaining\n` +
`GraphQL: ${detail.graphql.remaining.toLocaleString()}/${detail.graphql.limit.toLocaleString()} remaining\n` +
`Resets: ${resetTime}`
);
});
}

function resolveInitialTab(): TabId {
const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab;
Expand Down Expand Up @@ -553,11 +569,13 @@ export default function DashboardPage() {
<div class="flex justify-end">
<Show when={getGraphqlRateLimit()}>
{(rl) => (
<Tooltip content={`GraphQL API Rate Limits — resets at ${rl().resetAt.toLocaleTimeString()}`} placement="left" focusable>
<span class={`tabular-nums ${rl().remaining < rl().limit * 0.1 ? "text-warning" : ""}`}>
API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr
</span>
</Tooltip>
<div onPointerEnter={fetchAndSetRlDetail} onFocusIn={fetchAndSetRlDetail}>
<Tooltip content={rlDetail()} placement="left" focusable contentClass="whitespace-pre font-mono text-xs">
<span class={`tabular-nums ${rl().remaining < rl().limit * 0.1 ? "text-warning" : ""}`}>
API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr
</span>
</Tooltip>
</div>
)}
</Show>
</div>
Expand Down
74 changes: 72 additions & 2 deletions src/app/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { createSignal, createMemo, Show, For, onCleanup, onMount } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { config, updateConfig, setMonitoredRepo } from "../../stores/config";
import type { Config } from "../../stores/config";
Expand All @@ -8,8 +8,10 @@ import { clearCache } from "../../stores/cache";
import { pushNotification } from "../../lib/errors";
import { buildOrgAccessUrl } from "../../lib/oauth";
import { isSafeGitHubUrl, openGitHubUrl } from "../../lib/url";
import { relativeTime } from "../../lib/format";
import { fetchOrgs } from "../../services/api";
import { getClient } from "../../services/github";
import { getUsageSnapshot, getUsageResetAt, resetUsageData, checkAndResetIfExpired, SOURCE_LABELS } from "../../services/api-usage";
import OrgSelector from "../onboarding/OrgSelector";
import RepoSelector from "../onboarding/RepoSelector";
import Section from "./Section";
Expand Down Expand Up @@ -52,6 +54,9 @@ export default function SettingsPage() {
}
});

onMount(() => checkAndResetIfExpired());
const usageSnapshot = createMemo(() => getUsageSnapshot());

// Local copies for org/repo editing (committed on blur/change)
const [localOrgs, setLocalOrgs] = createSignal<string[]>(config.selectedOrgs);
const [localRepos, setLocalRepos] = createSignal<RepoRef[]>(config.selectedRepos);
Expand Down Expand Up @@ -403,7 +408,72 @@ export default function SettingsPage() {
</SettingRow>
</Section>

{/* Section 4: GitHub Actions */}
{/* Section 4: API Usage */}
<Section title="API Usage">
<div class="px-4 py-3 flex flex-col gap-3">
<Show
when={usageSnapshot().length > 0}
fallback={<p class="p-4 text-base-content/50">No API calls tracked yet.</p>}
>
<div class="overflow-x-auto">
<table class="table table-xs">
<thead>
<tr>
<th>Source</th>
<th>Pool</th>
<th>Calls</th>
<th>Last Called</th>
</tr>
</thead>
<tbody>
<For each={usageSnapshot()}>
{(record) => (
<tr>
<td>{SOURCE_LABELS[record.source] ?? record.source}</td>
<td>
<Show
when={record.pool === "graphql"}
fallback={<span class="badge badge-xs badge-outline">core</span>}
>
<span class="badge badge-xs badge-ghost">graphql</span>
</Show>
</td>
<td class="tabular-nums">{record.count.toLocaleString()}</td>
<td>{relativeTime(new Date(record.lastCalledAt).toISOString())}</td>
</tr>
)}
</For>
</tbody>
<tfoot>
<tr>
<td colSpan={2} class="font-medium">Total</td>
<td class="tabular-nums font-medium">
{usageSnapshot().reduce((sum, r) => sum + r.count, 0).toLocaleString()}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
</Show>
<div class="flex items-center justify-between flex-wrap gap-2">
<Show when={getUsageResetAt() != null}>
<p class="text-xs text-base-content/60">
Window resets at {new Date(getUsageResetAt()!).toLocaleTimeString()}
</p>
</Show>
<button
type="button"
onClick={() => resetUsageData()}
class="btn btn-xs btn-ghost"
>
Reset counts
</button>
</div>
</div>
</Section>

{/* Section 5: GitHub Actions */}
<Section title="GitHub Actions">
<SettingRow
label="Max workflows per repo"
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/shared/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface TooltipProps {
placement?: "top" | "bottom" | "left" | "right";
focusable?: boolean;
class?: string;
contentClass?: string;
children: JSX.Element;
}

Expand Down Expand Up @@ -60,7 +61,7 @@ export function Tooltip(props: TooltipProps) {
{props.children}
</KobalteTooltip.Trigger>
<KobalteTooltip.Portal>
<KobalteTooltip.Content class={TOOLTIP_CONTENT_CLASS}>
<KobalteTooltip.Content class={`${TOOLTIP_CONTENT_CLASS}${props.contentClass ? ` ${props.contentClass}` : ""}`}>
<KobalteTooltip.Arrow />
{props.content}
</KobalteTooltip.Content>
Expand Down
Loading