gh-glance 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -1
- package/README.md +39 -3
- package/index.mjs +348 -37
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-08-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Last-known-good dashboard rows now survive process restarts.** Successfully
|
|
15
|
+
parsed Actions, Issues, Pull Requests, and Security state is stored in a
|
|
16
|
+
private, target-scoped cache. It retains five recent targets and 60 rows per
|
|
17
|
+
tab, ignores corrupt or unwritable state, and never stores credentials.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Development tooling was refreshed.** The paired CodeQL actions now use
|
|
22
|
+
4.37.6, and the development-only `globals` lock entry is 17.9.0.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **Status updates no longer accumulate old footer lines during prolonged rate
|
|
27
|
+
limiting.** The dashboard leaves the terminal's physical bottom row as a
|
|
28
|
+
scroll guard while preserving incremental rendering.
|
|
29
|
+
- **Restarting during a live rate limit no longer shows an empty dashboard.**
|
|
30
|
+
Cached rows remain visible below the current error with an explicit `stale`
|
|
31
|
+
age, while repository targets remain isolated.
|
|
32
|
+
- **Blind Security refreshes preserve known alerts.** A failed observation
|
|
33
|
+
cannot replace last-known-good Security rows with a false empty result, and an
|
|
34
|
+
identical healthy response clears the warning after recovery.
|
|
35
|
+
|
|
10
36
|
## [0.7.0] - 2026-08-10
|
|
11
37
|
|
|
12
38
|
### Added
|
|
@@ -621,7 +647,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
621
647
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
622
648
|
but importing it took over the terminal or exited the host process.
|
|
623
649
|
|
|
624
|
-
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.
|
|
650
|
+
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.8.0...HEAD
|
|
651
|
+
[0.8.0]: https://github.com/juan294/gh-glance/compare/v0.7.0...v0.8.0
|
|
625
652
|
[0.7.0]: https://github.com/juan294/gh-glance/compare/v0.6.1...v0.7.0
|
|
626
653
|
[0.6.1]: https://github.com/juan294/gh-glance/releases/tag/v0.6.1
|
|
627
654
|
[0.6.0]: https://github.com/juan294/gh-glance/releases/tag/v0.6.0
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ without switching to the browser.
|
|
|
30
30
|
│ │
|
|
31
31
|
│ │
|
|
32
32
|
╰───────────────────────────────────────────────────────────────── 4 of 4 ─╯
|
|
33
|
-
⣾ Fetching Move: ↑↓ | Open: Ent | Refresh: r | Width: w | Quit: q 0.
|
|
33
|
+
⣾ Fetching Move: ↑↓ | Open: Ent | Refresh: r | Width: w | Quit: q 0.8.0
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> Captured from a real run at 76 columns with `GH_GLANCE_ICONS=unicode`, so the
|
|
@@ -90,10 +90,13 @@ current.
|
|
|
90
90
|
answers "is CI red" without colour
|
|
91
91
|
- Says when it is stale rather than showing old data silently, and marks a tab
|
|
92
92
|
whose last refresh failed
|
|
93
|
+
- Keeps the last successfully parsed rows visible during temporary failures and
|
|
94
|
+
across a restart, scoped to the repository being watched
|
|
93
95
|
- Adapts row count to the terminal pane's height live, on resize
|
|
94
96
|
- Enters the terminal's alternate screen buffer on launch (like `lazygit`,
|
|
95
97
|
`htop`, `vim`) so the shell prompt that launched it stays out of view, and
|
|
96
|
-
is restored cleanly on exit
|
|
98
|
+
is restored cleanly on exit without old status lines accumulating below the
|
|
99
|
+
frame
|
|
97
100
|
|
|
98
101
|
## Prerequisites
|
|
99
102
|
|
|
@@ -344,6 +347,32 @@ the live session and width mode reports `Widths not saved`; a later successful
|
|
|
344
347
|
write clears the warning. The UI owns this file, so direct editing is not the
|
|
345
348
|
primary configuration workflow.
|
|
346
349
|
|
|
350
|
+
### Last-known-good dashboard cache
|
|
351
|
+
|
|
352
|
+
Successfully parsed dashboard rows are also saved automatically, in
|
|
353
|
+
`dashboard-cache.json` beside `preferences.json`:
|
|
354
|
+
|
|
355
|
+
```text
|
|
356
|
+
$XDG_CONFIG_HOME/gh-glance/dashboard-cache.json
|
|
357
|
+
~/Library/Application Support/gh-glance/dashboard-cache.json
|
|
358
|
+
~/.config/gh-glance/dashboard-cache.json
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The cache is scoped to the repository and host being watched. In inferred mode,
|
|
362
|
+
the host and current working directory form the identity, so rows from one
|
|
363
|
+
checkout cannot appear in another. It retains at most five recent targets and
|
|
364
|
+
60 rows per tab; a shortened cached tab keeps its `+` marker rather than
|
|
365
|
+
presenting the saved count as complete.
|
|
366
|
+
|
|
367
|
+
Only successfully parsed, non-blind observations replace saved rows. A failed
|
|
368
|
+
request can add a live error or `?` marker, but it cannot turn known Security
|
|
369
|
+
alerts or other last-known-good rows into an empty result. Missing, corrupt,
|
|
370
|
+
unknown-version, or unwritable cache state is ignored rather than blocking the
|
|
371
|
+
dashboard. On POSIX systems, gh-glance restricts its config directory to `0700`
|
|
372
|
+
and the cache file to `0600`. The file contains repository data, including
|
|
373
|
+
titles, authors, branches, and Security findings, but never GitHub credentials.
|
|
374
|
+
It is recovery state owned by the UI, not a configuration interface.
|
|
375
|
+
|
|
347
376
|
## GitHub Enterprise and EMU
|
|
348
377
|
|
|
349
378
|
**The common case needs no configuration.** Authenticate once --
|
|
@@ -443,6 +472,13 @@ hour, an authorization failure every 30 seconds, a rate limit every minute.
|
|
|
443
472
|
Pressing `r` clears that backoff and retries immediately. And `--refresh`
|
|
444
473
|
scales the whole figure: doubling the interval halves it.
|
|
445
474
|
|
|
475
|
+
When a refresh fails, the last successful rows stay visible below the live
|
|
476
|
+
error instead of disappearing. If gh-glance is restarted while GitHub is still
|
|
477
|
+
rate-limiting, it loads the saved rows for that same target immediately. Their
|
|
478
|
+
age remains explicit through the `stale` label, and the rate-limit banner still
|
|
479
|
+
describes the current failed request; cached data never turns a failure into a
|
|
480
|
+
false success.
|
|
481
|
+
|
|
446
482
|
One pane on Actions costs about a third of an hourly REST budget, so roughly
|
|
447
483
|
three panes fill it. Past that, `gh-glance` widens its own poll interval: it
|
|
448
484
|
reads your remaining budget once a minute, works out how much of it belongs to
|
|
@@ -544,7 +580,7 @@ in some terminals, which would shift every column to their right.
|
|
|
544
580
|
| Security tab shows `?` instead of a number | The alert endpoints could not be read at all -- an expired SAML session, a token without `security_events`, or an org OAuth restriction. `?` means "unknown", not "zero"; run `gh-glance --doctor` to see which probe failed and how it was classified. |
|
|
545
581
|
| A failing tab seems to have stopped retrying | It backs off deliberately, rather than re-spawning `gh` every five seconds against an endpoint that is refusing. Press `r` to clear the backoff and retry now. |
|
|
546
582
|
| `unknown argument: -v` | `-v` used to mean `--version` and no longer does, because this CLI also has `--verbose`. Use `--version` or `--verbose` explicitly. |
|
|
547
|
-
| `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop. |
|
|
583
|
+
| `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop or rate limit. Last-known-good rows can remain visible, including after restart; the live error above the table explains why they are stale. |
|
|
548
584
|
| It exits immediately when piped | Intentional. It is a full-screen dashboard, not a reporting command. |
|
|
549
585
|
| It stopped updating and you cannot tell why | Run `gh-glance --verbose 2>gh-glance.log`, reproduce, then read the log: one line per `gh` call with its duration and outcome. It is redacted the same way `--doctor` is, so it is safe to attach to a bug report. `--doctor --verbose` logs the probes too. |
|
|
550
586
|
| `--verbose` refuses to start | stderr is still your terminal, where the log would draw over the dashboard. Redirect it to a file. |
|
package/index.mjs
CHANGED
|
@@ -24,6 +24,7 @@ process.env.NODE_ENV ??= "production";
|
|
|
24
24
|
|
|
25
25
|
import { execFile, spawn } from "node:child_process";
|
|
26
26
|
import {
|
|
27
|
+
chmodSync,
|
|
27
28
|
mkdirSync,
|
|
28
29
|
readFileSync,
|
|
29
30
|
realpathSync,
|
|
@@ -2981,8 +2982,8 @@ function saveWidthPreferences(path, overrides, tabs = TABS) {
|
|
|
2981
2982
|
}
|
|
2982
2983
|
}
|
|
2983
2984
|
|
|
2984
|
-
function
|
|
2985
|
-
let latest
|
|
2985
|
+
function createCoalescedWriter({ write, delay = 200, onResult = () => {} }) {
|
|
2986
|
+
let latest;
|
|
2986
2987
|
let generation = 0;
|
|
2987
2988
|
let persistedGeneration = 0;
|
|
2988
2989
|
let timer = null;
|
|
@@ -3083,6 +3084,227 @@ function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }
|
|
|
3083
3084
|
};
|
|
3084
3085
|
}
|
|
3085
3086
|
|
|
3087
|
+
const createWidthPreferenceWriter = createCoalescedWriter;
|
|
3088
|
+
|
|
3089
|
+
// ---------- Last-known-good dashboard cache ----------
|
|
3090
|
+
|
|
3091
|
+
const DASHBOARD_CACHE_VERSION = 1;
|
|
3092
|
+
const MAX_DASHBOARD_CACHE_TARGETS = 5;
|
|
3093
|
+
const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
|
|
3094
|
+
let dashboardCacheTempSequence = 0;
|
|
3095
|
+
|
|
3096
|
+
function dashboardCachePath(options = {}) {
|
|
3097
|
+
return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// Explicit targets are stable across working directories. In inferred mode the
|
|
3101
|
+
// directory is the only repository identity available without making another
|
|
3102
|
+
// GitHub call -- exactly the call that cannot succeed when this cache is needed.
|
|
3103
|
+
// JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
|
|
3104
|
+
function dashboardCacheTarget({ repo = null, ghRepo = null, host = null, cwd = process.cwd() } = {}) {
|
|
3105
|
+
const target = repo || ghRepo;
|
|
3106
|
+
return target
|
|
3107
|
+
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target) })
|
|
3108
|
+
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd) });
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function cacheTimestamp(value) {
|
|
3112
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function normalizeCachedItem(tabKey, item) {
|
|
3116
|
+
if (!isRecord(item)) return null;
|
|
3117
|
+
if (tabKey === "actions") {
|
|
3118
|
+
if (!Number.isSafeInteger(item.databaseId)) return null;
|
|
3119
|
+
return {
|
|
3120
|
+
databaseId: item.databaseId,
|
|
3121
|
+
displayTitle: safe(item.displayTitle),
|
|
3122
|
+
workflowName: safe(item.workflowName),
|
|
3123
|
+
number: Number.isSafeInteger(item.number) ? item.number : null,
|
|
3124
|
+
headBranch: safe(item.headBranch),
|
|
3125
|
+
status: safe(item.status),
|
|
3126
|
+
conclusion: item.conclusion == null ? null : safe(item.conclusion),
|
|
3127
|
+
startedAt: safe(item.startedAt),
|
|
3128
|
+
updatedAt: safe(item.updatedAt),
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
if (tabKey === "issues") {
|
|
3132
|
+
if (!Number.isSafeInteger(item.number)) return null;
|
|
3133
|
+
return {
|
|
3134
|
+
number: item.number,
|
|
3135
|
+
title: safe(item.title),
|
|
3136
|
+
author: safe(item.author),
|
|
3137
|
+
label: safe(item.label),
|
|
3138
|
+
updatedAt: safe(item.updatedAt),
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
3141
|
+
if (tabKey === "prs") {
|
|
3142
|
+
if (!Number.isSafeInteger(item.number)) return null;
|
|
3143
|
+
return {
|
|
3144
|
+
number: item.number,
|
|
3145
|
+
title: safe(item.title),
|
|
3146
|
+
author: safe(item.author),
|
|
3147
|
+
headRefName: safe(item.headRefName),
|
|
3148
|
+
isDraft: Boolean(item.isDraft),
|
|
3149
|
+
reviewDecision: safe(item.reviewDecision),
|
|
3150
|
+
updatedAt: safe(item.updatedAt),
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
if (tabKey === "security") {
|
|
3154
|
+
const id = safe(item.id);
|
|
3155
|
+
if (!id) return null;
|
|
3156
|
+
return {
|
|
3157
|
+
id,
|
|
3158
|
+
kind: safe(item.kind),
|
|
3159
|
+
severity: safe(item.severity),
|
|
3160
|
+
title: safe(item.title),
|
|
3161
|
+
detail: safe(item.detail),
|
|
3162
|
+
createdAt: safe(item.createdAt),
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
return null;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
function normalizeDashboardCacheEntry(entry) {
|
|
3169
|
+
if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
|
|
3170
|
+
const tabs = {};
|
|
3171
|
+
for (const tabKey of TAB_KEYS) {
|
|
3172
|
+
const tab = entry.tabs[tabKey];
|
|
3173
|
+
if (!isRecord(tab) || !Array.isArray(tab.data)) continue;
|
|
3174
|
+
const lastOk = cacheTimestamp(tab.lastOk);
|
|
3175
|
+
if (
|
|
3176
|
+
lastOk === null ||
|
|
3177
|
+
!isRecord(tab.meta) ||
|
|
3178
|
+
cacheTimestamp(tab.meta.at) === null ||
|
|
3179
|
+
typeof tab.meta.truncated !== "boolean"
|
|
3180
|
+
) {
|
|
3181
|
+
continue;
|
|
3182
|
+
}
|
|
3183
|
+
const normalizedData = tab.data.map((item) => normalizeCachedItem(tabKey, item)).filter(Boolean);
|
|
3184
|
+
// A malformed row means this tab was not written by the current schema.
|
|
3185
|
+
// Reject the tab rather than turning a corrupt non-empty payload into a
|
|
3186
|
+
// confident empty state.
|
|
3187
|
+
if (normalizedData.length !== tab.data.length) continue;
|
|
3188
|
+
const data = normalizedData.slice(0, MAX_DASHBOARD_CACHE_ROWS_PER_TAB);
|
|
3189
|
+
const meta = {
|
|
3190
|
+
at: tab.meta.at,
|
|
3191
|
+
truncated: tab.meta.truncated || normalizedData.length > MAX_DASHBOARD_CACHE_ROWS_PER_TAB,
|
|
3192
|
+
};
|
|
3193
|
+
tabs[tabKey] = { data, meta, lastOk };
|
|
3194
|
+
}
|
|
3195
|
+
if (Object.keys(tabs).length === 0) return null;
|
|
3196
|
+
const latestTab = Math.max(...Object.values(tabs).map((tab) => tab.lastOk));
|
|
3197
|
+
return {
|
|
3198
|
+
tabs,
|
|
3199
|
+
securityNotes: Array.isArray(entry.securityNotes)
|
|
3200
|
+
? entry.securityNotes
|
|
3201
|
+
.filter((note) => typeof note === "string")
|
|
3202
|
+
.slice(0, ALERT_SOURCES.length)
|
|
3203
|
+
.map((note) => safe(note))
|
|
3204
|
+
: [],
|
|
3205
|
+
securityBlind: typeof entry.securityBlind === "boolean" ? entry.securityBlind : false,
|
|
3206
|
+
updatedAt: cacheTimestamp(entry.updatedAt) ?? latestTab,
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
function normalizeDashboardCache(cache) {
|
|
3211
|
+
if (!isRecord(cache)) return {};
|
|
3212
|
+
return limitDashboardCache(
|
|
3213
|
+
Object.fromEntries(
|
|
3214
|
+
Object.entries(cache)
|
|
3215
|
+
.map(([target, entry]) => [target, normalizeDashboardCacheEntry(entry)])
|
|
3216
|
+
.filter(([target, entry]) => target.length > 0 && entry !== null),
|
|
3217
|
+
),
|
|
3218
|
+
);
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
function limitDashboardCache(cache) {
|
|
3222
|
+
return Object.fromEntries(
|
|
3223
|
+
Object.entries(cache)
|
|
3224
|
+
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
|
3225
|
+
.slice(0, MAX_DASHBOARD_CACHE_TARGETS),
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function mergeDashboardCacheEntry(cache, target, entry) {
|
|
3230
|
+
const normalized = normalizeDashboardCacheEntry(entry);
|
|
3231
|
+
return normalized === null ? cache : limitDashboardCache({ ...cache, [target]: normalized });
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
function decodeDashboardCache(raw) {
|
|
3235
|
+
let document;
|
|
3236
|
+
try {
|
|
3237
|
+
document = JSON.parse(raw);
|
|
3238
|
+
} catch (error) {
|
|
3239
|
+
return { cache: {}, error };
|
|
3240
|
+
}
|
|
3241
|
+
if (
|
|
3242
|
+
!isRecord(document) ||
|
|
3243
|
+
document.version !== DASHBOARD_CACHE_VERSION ||
|
|
3244
|
+
!isRecord(document.targets)
|
|
3245
|
+
) {
|
|
3246
|
+
return { cache: {}, error: new Error("Unsupported dashboard cache document") };
|
|
3247
|
+
}
|
|
3248
|
+
return { cache: normalizeDashboardCache(document.targets), error: null };
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
function serializeDashboardCache(cache, { normalized = false } = {}) {
|
|
3252
|
+
return `${JSON.stringify(
|
|
3253
|
+
{ version: DASHBOARD_CACHE_VERSION, targets: normalized ? cache : normalizeDashboardCache(cache) },
|
|
3254
|
+
null,
|
|
3255
|
+
2,
|
|
3256
|
+
)}\n`;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
function loadDashboardCache(path, target) {
|
|
3260
|
+
let raw;
|
|
3261
|
+
try {
|
|
3262
|
+
raw = readFileSync(path, "utf8");
|
|
3263
|
+
} catch (error) {
|
|
3264
|
+
return error?.code === "ENOENT"
|
|
3265
|
+
? { cache: {}, entry: null, error: null }
|
|
3266
|
+
: { cache: {}, entry: null, error };
|
|
3267
|
+
}
|
|
3268
|
+
const decoded = decodeDashboardCache(raw);
|
|
3269
|
+
return {
|
|
3270
|
+
cache: decoded.cache,
|
|
3271
|
+
entry: pick(decoded.cache, target, null),
|
|
3272
|
+
error: decoded.error,
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
function saveDashboardCache(path, cache, { normalized = false } = {}) {
|
|
3277
|
+
let tempPath = null;
|
|
3278
|
+
try {
|
|
3279
|
+
const parent = dirname(path);
|
|
3280
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
3281
|
+
if (process.platform !== "win32") chmodSync(parent, 0o700);
|
|
3282
|
+
dashboardCacheTempSequence += 1;
|
|
3283
|
+
tempPath = `${path}.${process.pid}.${Date.now()}.${dashboardCacheTempSequence}.tmp`;
|
|
3284
|
+
writeFileSync(tempPath, serializeDashboardCache(cache, { normalized }), {
|
|
3285
|
+
encoding: "utf8",
|
|
3286
|
+
mode: 0o600,
|
|
3287
|
+
flag: "wx",
|
|
3288
|
+
});
|
|
3289
|
+
renameSync(tempPath, path);
|
|
3290
|
+
return { ok: true };
|
|
3291
|
+
} catch (error) {
|
|
3292
|
+
if (tempPath !== null) {
|
|
3293
|
+
try {
|
|
3294
|
+
unlinkSync(tempPath);
|
|
3295
|
+
} catch {
|
|
3296
|
+
// The write may have failed before creating this exact temporary file.
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
return { ok: false, error };
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
function nextSecurityRaw(previousRaw, raw, blind) {
|
|
3304
|
+
if (blind === true) return null;
|
|
3305
|
+
return previousRaw === raw ? previousRaw : raw;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3086
3308
|
// Below this the full column set cannot render without its fixed columns
|
|
3087
3309
|
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
3088
3310
|
// repainting the whole screen every frame. Worse, in the band just above the
|
|
@@ -3409,19 +3631,42 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3409
3631
|
void widthPreferenceWriter.dispose();
|
|
3410
3632
|
};
|
|
3411
3633
|
}, [widthPreferenceWriter]);
|
|
3634
|
+
const [cachePath] = useState(() => dashboardCachePath());
|
|
3635
|
+
const [cacheTarget] = useState(() =>
|
|
3636
|
+
dashboardCacheTarget({
|
|
3637
|
+
repo: runtime.repo,
|
|
3638
|
+
ghRepo: process.env.GH_REPO,
|
|
3639
|
+
host: runtime.host ?? process.env.GH_HOST,
|
|
3640
|
+
cwd: process.cwd(),
|
|
3641
|
+
}),
|
|
3642
|
+
);
|
|
3643
|
+
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
3644
|
+
const dashboardCacheRef = useRef(loadedCache.cache);
|
|
3645
|
+
const dashboardCacheTargetRef = useRef(cacheTarget);
|
|
3646
|
+
const [dashboardCacheWriter] = useState(() =>
|
|
3647
|
+
createCoalescedWriter({
|
|
3648
|
+
write: (cache) => saveDashboardCache(cachePath, cache, { normalized: true }),
|
|
3649
|
+
}),
|
|
3650
|
+
);
|
|
3651
|
+
useEffect(() => () => {
|
|
3652
|
+
// Production writes start synchronously, so a pending latest snapshot is
|
|
3653
|
+
// not lost on quit. The cache is bounded; ignoring the advisory result keeps
|
|
3654
|
+
// a filesystem error from replacing terminal teardown.
|
|
3655
|
+
void dashboardCacheWriter.dispose();
|
|
3656
|
+
}, [dashboardCacheWriter]);
|
|
3657
|
+
const cachedEntry = loadedCache.entry;
|
|
3412
3658
|
// `null` means "never resolved" -- distinct from `[]`, which means "resolved
|
|
3413
3659
|
// and genuinely empty". The tab bar and the body render those differently.
|
|
3414
|
-
const [data, setData] = useState(
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
const [securityNotes, setSecurityNotes] = useState([]);
|
|
3660
|
+
const [data, setData] = useState(() =>
|
|
3661
|
+
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
|
|
3662
|
+
);
|
|
3663
|
+
const [meta, setMeta] = useState(() =>
|
|
3664
|
+
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
|
|
3665
|
+
);
|
|
3666
|
+
const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
|
|
3422
3667
|
// Whether the Security tab is currently unable to see its endpoints, as
|
|
3423
3668
|
// opposed to seeing that they are switched off. Drives the count marker.
|
|
3424
|
-
const [securityBlind, setSecurityBlind] = useState(false);
|
|
3669
|
+
const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
|
|
3425
3670
|
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
3426
3671
|
// still byte-identical and the redraw suppression is untouched.
|
|
3427
3672
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -3509,12 +3754,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3509
3754
|
|
|
3510
3755
|
const inFlightRef = useRef({});
|
|
3511
3756
|
const rawRef = useRef({});
|
|
3512
|
-
const cleanRef = useRef({});
|
|
3513
3757
|
// Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
|
|
3514
3758
|
// sleeping is exactly the gap this is meant to report, and a monotonic clock
|
|
3515
3759
|
// does not advance across suspend. Never written on the failure path, or a
|
|
3516
3760
|
// persistently failing tab would report itself fresh forever.
|
|
3517
|
-
const lastOkRef = useRef(
|
|
3761
|
+
const lastOkRef = useRef(
|
|
3762
|
+
Object.fromEntries(
|
|
3763
|
+
TABS.flatMap((candidate) => {
|
|
3764
|
+
const lastOk = cachedEntry?.tabs[candidate.key]?.lastOk;
|
|
3765
|
+
return lastOk == null ? [] : [[candidate.key, lastOk]];
|
|
3766
|
+
}),
|
|
3767
|
+
),
|
|
3768
|
+
);
|
|
3518
3769
|
const fetchTabRef = useRef(null);
|
|
3519
3770
|
const contextCoordinatorRef = useRef(null);
|
|
3520
3771
|
|
|
@@ -3881,10 +4132,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3881
4132
|
// -- the behaviour the per-tab guard and the fetch-time limit read were
|
|
3882
4133
|
// written to prevent. Do not "fix" this by adding them.
|
|
3883
4134
|
//
|
|
3884
|
-
// exhaustive-deps is satisfied as written -- every captured value is
|
|
3885
|
-
//
|
|
3886
|
-
//
|
|
3887
|
-
//
|
|
4135
|
+
// exhaustive-deps is satisfied as written -- every captured value is a ref,
|
|
4136
|
+
// a setState function, or the stable cache writer created once above. If a
|
|
4137
|
+
// changing dependency ever becomes necessary, that is the signal that the
|
|
4138
|
+
// mount-only contract has been broken.
|
|
3888
4139
|
//
|
|
3889
4140
|
// The adaptive throttle re-arms the timer, which is not a contradiction of the
|
|
3890
4141
|
// above: `rearm` swaps the interval from *inside* this effect, so the effect
|
|
@@ -3915,6 +4166,32 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3915
4166
|
});
|
|
3916
4167
|
contextCoordinatorRef.current = coordinator;
|
|
3917
4168
|
|
|
4169
|
+
function cacheSuccessfulTab(key, tabData, tabMeta, lastOk, security = {}) {
|
|
4170
|
+
const target = dashboardCacheTargetRef.current;
|
|
4171
|
+
const currentCache = dashboardCacheRef.current;
|
|
4172
|
+
const currentEntry = pick(currentCache, target, null) ?? {
|
|
4173
|
+
tabs: {},
|
|
4174
|
+
securityNotes: [],
|
|
4175
|
+
securityBlind: false,
|
|
4176
|
+
updatedAt: lastOk,
|
|
4177
|
+
};
|
|
4178
|
+
const nextEntry = {
|
|
4179
|
+
...currentEntry,
|
|
4180
|
+
tabs: {
|
|
4181
|
+
...currentEntry.tabs,
|
|
4182
|
+
[key]: { data: tabData, meta: tabMeta, lastOk },
|
|
4183
|
+
},
|
|
4184
|
+
securityNotes:
|
|
4185
|
+
key === "security" ? (security.notes ?? []) : currentEntry.securityNotes,
|
|
4186
|
+
securityBlind:
|
|
4187
|
+
key === "security" ? Boolean(security.blind) : currentEntry.securityBlind,
|
|
4188
|
+
updatedAt: lastOk,
|
|
4189
|
+
};
|
|
4190
|
+
const nextCache = mergeDashboardCacheEntry(currentCache, target, nextEntry);
|
|
4191
|
+
dashboardCacheRef.current = nextCache;
|
|
4192
|
+
dashboardCacheWriter.schedule(nextCache);
|
|
4193
|
+
}
|
|
4194
|
+
|
|
3918
4195
|
// Each tab commits its own result the moment it lands instead of waiting on
|
|
3919
4196
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
3920
4197
|
// barrelling everything together meant the three fast tabs sat invisible
|
|
@@ -3953,32 +4230,50 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3953
4230
|
// completely healthy. A ref rather than state because writing state here
|
|
3954
4231
|
// would allocate a new object every tick and permanently defeat the
|
|
3955
4232
|
// React bail-out this early return exists to preserve.
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
4233
|
+
const completedAt = Date.now();
|
|
4234
|
+
if (rawRef.current[key] === raw) {
|
|
4235
|
+
lastOkRef.current[key] = completedAt;
|
|
4236
|
+
// Clear on the first success or a single failure latches the ladder.
|
|
4237
|
+
clearBackoff(`tab:${key}`);
|
|
3960
4238
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
3961
4239
|
return;
|
|
3962
4240
|
}
|
|
3963
4241
|
const value = parse();
|
|
3964
|
-
|
|
3965
|
-
|
|
4242
|
+
// Security fetches resolve each source independently so their notes
|
|
4243
|
+
// remain visible. A blind result is still a failed observation: it
|
|
4244
|
+
// must not replace known alerts with a false empty state or advance
|
|
4245
|
+
// freshness. Do not retain its raw value either, so the next source
|
|
4246
|
+
// retry is parsed instead of taking the identical-payload fast path.
|
|
4247
|
+
if (key === "security" && value.blind) {
|
|
4248
|
+
rawRef.current[key] = nextSecurityRaw(rawRef.current[key], raw, true);
|
|
4249
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
4250
|
+
setSecurityNotes(value.notes ?? []);
|
|
4251
|
+
setSecurityBlind(true);
|
|
4252
|
+
return;
|
|
4253
|
+
}
|
|
4254
|
+
const tabData = value.alerts ?? value;
|
|
4255
|
+
const tabMeta = {
|
|
4256
|
+
at: completedAt,
|
|
4257
|
+
// Compare against the limit this payload was actually fetched
|
|
4258
|
+
// with. Comparing against the live pane-height ref made the "n+"
|
|
4259
|
+
// marker lie for one cycle after every resize.
|
|
4260
|
+
truncated: value.truncated ?? tabData.length >= limit,
|
|
4261
|
+
};
|
|
4262
|
+
lastOkRef.current[key] = completedAt;
|
|
4263
|
+
clearBackoff(`tab:${key}`);
|
|
4264
|
+
rawRef.current[key] =
|
|
4265
|
+
key === "security" ? nextSecurityRaw(rawRef.current[key], raw, false) : raw;
|
|
3966
4266
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
3967
|
-
setData((d) => ({ ...d, [key]:
|
|
3968
|
-
setMeta((m) => ({
|
|
3969
|
-
...m,
|
|
3970
|
-
[key]: {
|
|
3971
|
-
at: Date.now(),
|
|
3972
|
-
// Compare against the limit this payload was actually fetched
|
|
3973
|
-
// with. Comparing against the live pane-height ref made the "n+"
|
|
3974
|
-
// marker lie for one cycle after every resize.
|
|
3975
|
-
truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
|
|
3976
|
-
},
|
|
3977
|
-
}));
|
|
4267
|
+
setData((d) => ({ ...d, [key]: tabData }));
|
|
4268
|
+
setMeta((m) => ({ ...m, [key]: tabMeta }));
|
|
3978
4269
|
if (value.notes) setSecurityNotes(value.notes);
|
|
3979
4270
|
if (key === "security") {
|
|
3980
4271
|
setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
|
|
3981
4272
|
}
|
|
4273
|
+
cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
|
|
4274
|
+
notes: value.notes,
|
|
4275
|
+
blind: value.blind,
|
|
4276
|
+
});
|
|
3982
4277
|
})
|
|
3983
4278
|
.catch((err) => {
|
|
3984
4279
|
// A rejected fetch carries no result, so its own spend is unknowable
|
|
@@ -3987,7 +4282,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3987
4282
|
// errs toward throttling harder, which is the safe direction.
|
|
3988
4283
|
restSpentTotal += REST_PER_FETCH[key] ?? 0;
|
|
3989
4284
|
if (cancelled || err?.name === "AbortError") return;
|
|
3990
|
-
cleanRef.current[key] = false;
|
|
3991
4285
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
3992
4286
|
// renderer translates recognized verdicts at draw time, which lets a
|
|
3993
4287
|
// later repository/account context refine the one-line remedy without
|
|
@@ -4121,7 +4415,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4121
4415
|
// needed.
|
|
4122
4416
|
controller.abort();
|
|
4123
4417
|
};
|
|
4124
|
-
}, []);
|
|
4418
|
+
}, [dashboardCacheWriter]);
|
|
4125
4419
|
|
|
4126
4420
|
// Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
|
|
4127
4421
|
// switch to refreshes straight away rather than showing old data until its
|
|
@@ -4358,9 +4652,19 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4358
4652
|
widthPreferenceWriter,
|
|
4359
4653
|
]);
|
|
4360
4654
|
|
|
4655
|
+
// Keep one physical row below Ink's output, the vertical counterpart to the
|
|
4656
|
+
// spare column in `frameCols`. Incremental rendering assumes its cursor still
|
|
4657
|
+
// sits at the bottom of the previous frame; if dynamic content occupies the
|
|
4658
|
+
// terminal's last row, a terminal scroll can invalidate that assumption and
|
|
4659
|
+
// leave old status lines behind. A non-fullscreen frame gets a trailing
|
|
4660
|
+
// newline from Ink, so the cursor parks on the unused guard row while the
|
|
4661
|
+
// status bar remains one row above the scroll edge.
|
|
4662
|
+
const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
|
|
4663
|
+
const frameRows = Math.max(1, liveRows - 1);
|
|
4664
|
+
|
|
4361
4665
|
return e(
|
|
4362
4666
|
Box,
|
|
4363
|
-
{ flexDirection: "column", width: frameCols, height:
|
|
4667
|
+
{ flexDirection: "column", width: frameCols, height: frameRows },
|
|
4364
4668
|
e(TabBar, {
|
|
4365
4669
|
activeIndex,
|
|
4366
4670
|
counts,
|
|
@@ -4818,6 +5122,13 @@ export {
|
|
|
4818
5122
|
loadWidthPreferences,
|
|
4819
5123
|
saveWidthPreferences,
|
|
4820
5124
|
createWidthPreferenceWriter,
|
|
5125
|
+
DASHBOARD_CACHE_VERSION,
|
|
5126
|
+
dashboardCachePath,
|
|
5127
|
+
dashboardCacheTarget,
|
|
5128
|
+
serializeDashboardCache,
|
|
5129
|
+
loadDashboardCache,
|
|
5130
|
+
saveDashboardCache,
|
|
5131
|
+
nextSecurityRaw,
|
|
4821
5132
|
runStatusIcon,
|
|
4822
5133
|
RUN_STATUS_ICON,
|
|
4823
5134
|
SEVERITY_STYLE,
|
package/package.json
CHANGED