gh-glance 0.18.1 → 0.19.1
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 +40 -1
- package/README.md +79 -26
- package/index.mjs +937 -126
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -567,10 +567,12 @@ function unavailableRemedy(accounts, targetHost) {
|
|
|
567
567
|
return candidate.length <= MAX_ERR_LENGTH ? candidate : VERDICT_REMEDY.unavailable;
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
const BUDGET_PAUSE_ERROR = /^API budget paused(?:\s|\()/i;
|
|
571
|
+
|
|
570
572
|
function formatTabError(error, failureContext = null) {
|
|
571
573
|
if (error == null) return null;
|
|
572
574
|
if (error.kind === "text") return error.text;
|
|
573
|
-
if (
|
|
575
|
+
if (BUDGET_PAUSE_ERROR.test(error.raw ?? "")) {
|
|
574
576
|
return "GitHub checks are paused until the shared API budget allows them";
|
|
575
577
|
}
|
|
576
578
|
if (error.verdict === "other") return error.raw;
|
|
@@ -12360,6 +12362,7 @@ const { version } = JSON.parse(readFileSync(new URL("./package.json", import.met
|
|
|
12360
12362
|
// this file already applies to argv vectors and to classify().
|
|
12361
12363
|
const KEY_TABLE = [
|
|
12362
12364
|
["1 2 3 4", "Actions / Issues / Pull requests / Security"],
|
|
12365
|
+
["5", "Notifications: status history; Up / Down scroll, r checks GitHub status, Esc returns"],
|
|
12363
12366
|
["Left / Right", "Previous / next tab"],
|
|
12364
12367
|
["Tab / Shift+Tab", "Next / previous tab"],
|
|
12365
12368
|
["Up / Down, j / k", "Move the cursor between rows"],
|
|
@@ -12389,6 +12392,7 @@ const HELP_PRIORITY = [
|
|
|
12389
12392
|
"Enter",
|
|
12390
12393
|
"Up / Down, j / k",
|
|
12391
12394
|
"1 2 3 4",
|
|
12395
|
+
"5",
|
|
12392
12396
|
"w",
|
|
12393
12397
|
"?",
|
|
12394
12398
|
"Left / Right",
|
|
@@ -12524,6 +12528,9 @@ Environment:
|
|
|
12524
12528
|
Minimum active-tab poll interval, ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS};
|
|
12525
12529
|
safe shared grants may run later
|
|
12526
12530
|
(--refresh takes precedence)
|
|
12531
|
+
GH_GLANCE_GITHUB_STATUS=off
|
|
12532
|
+
Turn off the githubstatus.com check in
|
|
12533
|
+
Notifications (an http(s) URL replaces the endpoint)
|
|
12527
12534
|
GH_GLANCE_ICONS=unicode Unicode status glyphs and text row substitutes
|
|
12528
12535
|
GH_GLANCE_ICONS=ascii ASCII-only status and row icons
|
|
12529
12536
|
GH_GLANCE_NO_ANIMATION=1 Stop motion; semantic status words remain
|
|
@@ -16632,10 +16639,14 @@ function cleanupQueueForResource(queue, resource) {
|
|
|
16632
16639
|
return count;
|
|
16633
16640
|
}
|
|
16634
16641
|
|
|
16642
|
+
function recoveryResourceLabel(resource) {
|
|
16643
|
+
return resource === "graphql" ? "GraphQL" : resource === "core" ? "Core" : "GitHub";
|
|
16644
|
+
}
|
|
16645
|
+
|
|
16635
16646
|
function presentRecovery(cause, { nowMs = Date.now(), cols = 80, hasSnapshot } = {}) {
|
|
16636
16647
|
if (!cause) return [];
|
|
16637
16648
|
const width = Math.max(1, Number.isSafeInteger(cols) ? cols - 1 : 79);
|
|
16638
|
-
const resource = cause.resource
|
|
16649
|
+
const resource = recoveryResourceLabel(cause.resource);
|
|
16639
16650
|
const age = hasSnapshot === false ? "Not checked yet"
|
|
16640
16651
|
: Number.isFinite(cause.sourceAt) && cause.sourceAt <= nowMs
|
|
16641
16652
|
? `cached ${formatDuration(nowMs - cause.sourceAt)}`
|
|
@@ -16817,6 +16828,7 @@ function contextHelpPages({ view, target, tab, remoteSetup = false }, keys = key
|
|
|
16817
16828
|
issues: "Issues shows open issues in the selected repository.",
|
|
16818
16829
|
prs: "Pull requests shows open requests to merge changes into the selected repository.",
|
|
16819
16830
|
security: "Security shows alerts returned by the checked sources; unavailable sources can hide alerts.",
|
|
16831
|
+
notifications: "Notifications lists status events for this pane: waits, refreshes, failures and cleared causes.",
|
|
16820
16832
|
};
|
|
16821
16833
|
const title = remoteSetup ? "No GitHub remote found" : view.title;
|
|
16822
16834
|
const action = remoteSetup ? "Close help with ?, then Enter to start gh repo create; or restart with gh-glance --repo owner/name."
|
|
@@ -16979,6 +16991,587 @@ function safeRecordRecoveryTransition(scope, event, write = recordRecoveryTransi
|
|
|
16979
16991
|
try { return write(scope, event); } catch { return { ok: false, reason: "unavailable" }; }
|
|
16980
16992
|
}
|
|
16981
16993
|
|
|
16994
|
+
// ---------- Notifications ----------
|
|
16995
|
+
|
|
16996
|
+
// Only causes the user must act on, or that waiting cannot fix, earn rows above
|
|
16997
|
+
// a table. Everything else -- automatic retries, refreshes, cleared causes --
|
|
16998
|
+
// goes to the Notifications view, so an ordinary wait no longer pushes the
|
|
16999
|
+
// table down. Every RECOVERY_COPY key is listed explicitly: the unknown-code
|
|
17000
|
+
// fallback below would otherwise hide a new code nobody classified, and
|
|
17001
|
+
// test/notifications.test.mjs compares the two key sets.
|
|
17002
|
+
const NOTICE_ACTION_CODES = Object.freeze([
|
|
17003
|
+
"unwritable", "corrupt", "capacity", "accounting-overflow", "reservations-invalid",
|
|
17004
|
+
"external-factor-invalid", "pacing-invalid", "budget-resource", "legacy-unresolved",
|
|
17005
|
+
"legacy-corrupt", "restart-required", "throttle-paused", "credential-unavailable",
|
|
17006
|
+
"identity-unavailable", "disconnected", "app-auth", "auth-problem", "unavailable",
|
|
17007
|
+
"backup-conflict", "version-mismatch", "unsafe-permissions", "disk-full",
|
|
17008
|
+
]);
|
|
17009
|
+
// `coordination` is the fallback copy for unmapped reasons (an external quota
|
|
17010
|
+
// drain among them), and `unknown-scope` is the startup login check; the real
|
|
17011
|
+
// storage and login failures have their own action codes above.
|
|
17012
|
+
const NOTICE_WAIT_CODES = Object.freeze([
|
|
17013
|
+
"busy", "stale", "identity-busy", "transport-busy", "debt-journal-full", "migration-hold",
|
|
17014
|
+
"identity-backoff", "identity-capacity", "budget-unknown", "budget-stale", "budget-future",
|
|
17015
|
+
"probe-failed", "block-unpublished", "budget-reset", "reset", "priority", "rate-limit",
|
|
17016
|
+
"rate-limited", "local-reserve", "reserve", "blocked", "secondary", "secondary-rate-limit",
|
|
17017
|
+
"abuse-limit", "network-outage", "security-incomplete", "receipt-retry", "interrupted-request",
|
|
17018
|
+
"request-queue", "acquisition-busy", "clock-recovery", "source-incomplete", "unknown-scope",
|
|
17019
|
+
"coordination",
|
|
17020
|
+
]);
|
|
17021
|
+
const NOTICE_LEVELS = Object.freeze(Object.fromEntries([
|
|
17022
|
+
...NOTICE_ACTION_CODES.map((code) => [code, "action"]),
|
|
17023
|
+
...NOTICE_WAIT_CODES.map((code) => [code, "wait"]),
|
|
17024
|
+
]));
|
|
17025
|
+
const VERDICT_NOTICE_LEVELS = Object.freeze({
|
|
17026
|
+
"auth-problem": "action", unavailable: "action", "no-remote": "action", "rate-limited": "wait",
|
|
17027
|
+
});
|
|
17028
|
+
const NOTIFICATION_LOG_MAX = 500;
|
|
17029
|
+
// Tabs whose requests draw on each rate resource; a cause on that resource
|
|
17030
|
+
// holds them up. The cost table is fixed, so this is computed once.
|
|
17031
|
+
const NOTICE_RESOURCE_TABS = Object.freeze(Object.fromEntries(RATE_RESOURCES.map((resource) =>
|
|
17032
|
+
[resource, TAB_KEYS.filter((key) => tabRequestCost(key)?.[resource] > 0)])));
|
|
17033
|
+
const EARLIER_SESSION_SOURCE = "Earlier session";
|
|
17034
|
+
|
|
17035
|
+
// When a governor hold says it will try again.
|
|
17036
|
+
function decisionRetryAt(decision) {
|
|
17037
|
+
return decision?.retryAt ?? decision?.resetMs ?? decision?.notBefore ?? null;
|
|
17038
|
+
}
|
|
17039
|
+
|
|
17040
|
+
function noticeLevel({ code = null, verdict = null } = {}) {
|
|
17041
|
+
if (code != null) return Object.hasOwn(NOTICE_LEVELS, code) ? NOTICE_LEVELS[code] : "wait";
|
|
17042
|
+
// Any other verdict is a failed refresh whose raw text is the best report.
|
|
17043
|
+
if (verdict != null) return Object.hasOwn(VERDICT_NOTICE_LEVELS, verdict) ? VERDICT_NOTICE_LEVELS[verdict] : "problem";
|
|
17044
|
+
return "info";
|
|
17045
|
+
}
|
|
17046
|
+
|
|
17047
|
+
function fetchErrorLevel(error) {
|
|
17048
|
+
if (!error) return null;
|
|
17049
|
+
if (error.kind !== "fetch") return "problem";
|
|
17050
|
+
// A shared-budget pause reaches the tab as an unclassified fetch error.
|
|
17051
|
+
if (BUDGET_PAUSE_ERROR.test(error.raw ?? "")) return "wait";
|
|
17052
|
+
return noticeLevel({ verdict: error.verdict });
|
|
17053
|
+
}
|
|
17054
|
+
|
|
17055
|
+
// One bounded, in-memory history per pane. Entries hold sanitized display text
|
|
17056
|
+
// only. A repeat of the latest entry with the same key coalesces into a count
|
|
17057
|
+
// (an informational repeat only if no non-info entry from the same source, or
|
|
17058
|
+
// one affecting the same tab, came after it); an ended entry never reopens, so a recurring condition reads
|
|
17059
|
+
// as a new episode. An entry the bound already dropped cannot be ended, so its
|
|
17060
|
+
// "Cleared" line is silently skipped. Earlier-session history is context, not
|
|
17061
|
+
// news, so it never raises the badge.
|
|
17062
|
+
function createNotificationLog({ max = NOTIFICATION_LOG_MAX, now = Date.now } = {}) {
|
|
17063
|
+
const entries = []; // oldest first
|
|
17064
|
+
let nextId = 1;
|
|
17065
|
+
let seenId = 0;
|
|
17066
|
+
let truncated = false;
|
|
17067
|
+
let cachedSummary = null;
|
|
17068
|
+
let cachedList = null;
|
|
17069
|
+
const changed = () => {
|
|
17070
|
+
cachedSummary = null;
|
|
17071
|
+
cachedList = null;
|
|
17072
|
+
};
|
|
17073
|
+
const timeOr = (value) => Number.isFinite(value) ? value : now();
|
|
17074
|
+
function record({ key, level = "info", source = "", code = null, text = "", action = null,
|
|
17075
|
+
at, lastAt, tabs = [] }) {
|
|
17076
|
+
const firstAt = timeOr(at);
|
|
17077
|
+
const latest = Number.isFinite(lastAt) ? Math.max(lastAt, firstAt) : firstAt;
|
|
17078
|
+
const cleanSource = safe(source);
|
|
17079
|
+
const cleanText = safe(text);
|
|
17080
|
+
let index = -1;
|
|
17081
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
17082
|
+
const entry = entries[i];
|
|
17083
|
+
if (entry.key === key) {
|
|
17084
|
+
if (entry.endedAt === null) index = i;
|
|
17085
|
+
break;
|
|
17086
|
+
}
|
|
17087
|
+
// Only informational repeats (refreshes) are split by a later problem; a
|
|
17088
|
+
// still-open condition recorded again is the same condition, updated.
|
|
17089
|
+
if (level === "info" && entry.level !== "info" && (entry.source === cleanSource ||
|
|
17090
|
+
entry.tabs.some((tab) => tabs.includes(tab)))) break;
|
|
17091
|
+
}
|
|
17092
|
+
if (index >= 0) {
|
|
17093
|
+
const [entry] = entries.splice(index, 1);
|
|
17094
|
+
// A refresh repeats; a changed open condition is the same occurrence.
|
|
17095
|
+
if (level === "info") entry.count += 1;
|
|
17096
|
+
entry.lastAt = Math.max(entry.lastAt, latest);
|
|
17097
|
+
entry.text = cleanText;
|
|
17098
|
+
entries.push(entry);
|
|
17099
|
+
} else {
|
|
17100
|
+
entries.push({ id: nextId, key, level, source: cleanSource, code,
|
|
17101
|
+
text: cleanText, action: action == null ? null : safe(action),
|
|
17102
|
+
firstAt, lastAt: latest, count: 1, endedAt: null, tabs: [...tabs] });
|
|
17103
|
+
nextId += 1;
|
|
17104
|
+
if (entries.length > max) {
|
|
17105
|
+
entries.shift();
|
|
17106
|
+
truncated = true;
|
|
17107
|
+
}
|
|
17108
|
+
}
|
|
17109
|
+
changed();
|
|
17110
|
+
}
|
|
17111
|
+
function end(key, at) {
|
|
17112
|
+
const entry = entries.findLast((candidate) => candidate.key === key);
|
|
17113
|
+
if (!entry || entry.endedAt !== null) return false;
|
|
17114
|
+
entry.endedAt = timeOr(at);
|
|
17115
|
+
changed();
|
|
17116
|
+
if (entry.level !== "info") {
|
|
17117
|
+
record({ key: `${key}:cleared`, level: "info", source: entry.source,
|
|
17118
|
+
text: `Cleared: ${entry.text}`, at: entry.endedAt, tabs: entry.tabs });
|
|
17119
|
+
}
|
|
17120
|
+
return true;
|
|
17121
|
+
}
|
|
17122
|
+
function summary() {
|
|
17123
|
+
if (cachedSummary) return cachedSummary;
|
|
17124
|
+
let unread = 0;
|
|
17125
|
+
let red = false;
|
|
17126
|
+
let yellow = false;
|
|
17127
|
+
for (const entry of entries) {
|
|
17128
|
+
if (entry.level === "info" || entry.source === EARLIER_SESSION_SOURCE || entry.id <= seenId) continue;
|
|
17129
|
+
unread += 1;
|
|
17130
|
+
if (entry.level === "wait") yellow = true;
|
|
17131
|
+
else red = true;
|
|
17132
|
+
}
|
|
17133
|
+
cachedSummary = { unread, tone: red ? "red" : yellow ? "yellow" : "none", truncated, size: entries.length };
|
|
17134
|
+
return cachedSummary;
|
|
17135
|
+
}
|
|
17136
|
+
return {
|
|
17137
|
+
record,
|
|
17138
|
+
end,
|
|
17139
|
+
summary,
|
|
17140
|
+
// Newest first. Shared between calls until the log changes; callers read only.
|
|
17141
|
+
list: () => {
|
|
17142
|
+
cachedList ??= entries.map((entry) => Object.freeze({ ...entry, tabs: Object.freeze([...entry.tabs]) })).reverse();
|
|
17143
|
+
return cachedList;
|
|
17144
|
+
},
|
|
17145
|
+
markSeen: () => {
|
|
17146
|
+
if (seenId === nextId - 1) return;
|
|
17147
|
+
seenId = nextId - 1;
|
|
17148
|
+
changed();
|
|
17149
|
+
},
|
|
17150
|
+
size: () => entries.length,
|
|
17151
|
+
};
|
|
17152
|
+
}
|
|
17153
|
+
|
|
17154
|
+
// Conditions present in one render's snapshot. The same cause reported by a
|
|
17155
|
+
// rate resource and by several tabs is one condition; the resource names it
|
|
17156
|
+
// when it has one, because that is where it lives.
|
|
17157
|
+
function notificationConditions(snapshot) {
|
|
17158
|
+
const conditions = new Map();
|
|
17159
|
+
// `tabs` names the sections a cause holds up, so a refresh of one of them
|
|
17160
|
+
// after the cause never merges with a refresh from before it.
|
|
17161
|
+
const cause = (reason, source, tabs, detail = null) => {
|
|
17162
|
+
// Same normalization as recoveryCause: unknown reasons use the fallback copy.
|
|
17163
|
+
const code = Object.hasOwn(RECOVERY_COPY, reason) ? reason : "coordination";
|
|
17164
|
+
const [summary, action] = RECOVERY_COPY[code];
|
|
17165
|
+
const key = `cause:${code}`;
|
|
17166
|
+
const existing = conditions.get(key);
|
|
17167
|
+
if (existing) {
|
|
17168
|
+
for (const tab of tabs) if (!existing.tabs.includes(tab)) existing.tabs.push(tab);
|
|
17169
|
+
return;
|
|
17170
|
+
}
|
|
17171
|
+
conditions.set(key, { key, level: noticeLevel({ code }), source, code,
|
|
17172
|
+
text: detail?.summary ?? summary, action: detail?.action ?? action, tabs: [...tabs] });
|
|
17173
|
+
};
|
|
17174
|
+
for (const resource of RATE_RESOURCES) {
|
|
17175
|
+
const recovery = snapshot?.recovery?.[resource];
|
|
17176
|
+
if (recovery?.code) {
|
|
17177
|
+
cause(recovery.code, recoveryResourceLabel(resource), NOTICE_RESOURCE_TABS[resource], recovery);
|
|
17178
|
+
}
|
|
17179
|
+
}
|
|
17180
|
+
for (const tab of TABS) {
|
|
17181
|
+
const state = snapshot?.tabs?.[tab.key];
|
|
17182
|
+
if (!state) continue;
|
|
17183
|
+
if (state.error) {
|
|
17184
|
+
// A budget pause and a real failure share the `other` verdict; the level
|
|
17185
|
+
// keeps them distinct so one never hides the other.
|
|
17186
|
+
const level = fetchErrorLevel(state.error);
|
|
17187
|
+
const key = `fetch:${tab.key}:${level}:${state.error.kind === "fetch" ? state.error.verdict : "text"}`;
|
|
17188
|
+
conditions.set(key, { key, level, source: tab.label, code: null,
|
|
17189
|
+
text: formatTabError(state.error, snapshot.failureContext ?? null) ?? "", action: null, tabs: [tab.key] });
|
|
17190
|
+
}
|
|
17191
|
+
if (state.failure) cause(state.failure, tab.label, [tab.key]);
|
|
17192
|
+
if (state.paused) cause(state.paused, tab.label, [tab.key]);
|
|
17193
|
+
}
|
|
17194
|
+
for (const note of snapshot?.securityNotes ?? []) {
|
|
17195
|
+
const text = safe(note);
|
|
17196
|
+
const key = `security:${text}`;
|
|
17197
|
+
conditions.set(key, { key, level: /not enabled/i.test(text) ? "info" : "wait",
|
|
17198
|
+
source: "Security", code: null, text, action: null, tabs: ["security"] });
|
|
17199
|
+
}
|
|
17200
|
+
if (snapshot?.disconnected) {
|
|
17201
|
+
cause(typeof snapshot.disconnected === "string" ? snapshot.disconnected : "disconnected",
|
|
17202
|
+
"Connection", TAB_KEYS);
|
|
17203
|
+
}
|
|
17204
|
+
return conditions;
|
|
17205
|
+
}
|
|
17206
|
+
|
|
17207
|
+
// Diff two render snapshots into log events. A condition must persist for the
|
|
17208
|
+
// same debounce the inline notice used, so startup and lock-contention blips
|
|
17209
|
+
// never become history; `pending` is when the earliest unconfirmed condition
|
|
17210
|
+
// becomes due, for the caller to re-check without waiting for another render.
|
|
17211
|
+
function diffNotificationSnapshots(previous, current, nowMs) {
|
|
17212
|
+
const prior = previous?.conditions ?? new Map();
|
|
17213
|
+
const conditions = new Map();
|
|
17214
|
+
const begin = [];
|
|
17215
|
+
const end = [];
|
|
17216
|
+
let pending = null;
|
|
17217
|
+
for (const [key, condition] of notificationConditions(current)) {
|
|
17218
|
+
const before = prior.get(key);
|
|
17219
|
+
const since = before?.since ?? nowMs;
|
|
17220
|
+
const begun = Boolean(before?.begun) || nowMs - since >= COORDINATION_NOTICE_AFTER_MS;
|
|
17221
|
+
if (begun && !before?.begun) begin.push({ ...condition, at: since });
|
|
17222
|
+
if (!begun) {
|
|
17223
|
+
const due = since + COORDINATION_NOTICE_AFTER_MS;
|
|
17224
|
+
pending = pending === null ? due : Math.min(pending, due);
|
|
17225
|
+
}
|
|
17226
|
+
conditions.set(key, { since, begun });
|
|
17227
|
+
}
|
|
17228
|
+
for (const [key, before] of prior) if (before.begun && !conditions.has(key)) end.push(key);
|
|
17229
|
+
const lastOk = {};
|
|
17230
|
+
for (const tab of TABS) {
|
|
17231
|
+
const before = previous?.lastOk?.[tab.key];
|
|
17232
|
+
const value = current?.tabs?.[tab.key]?.lastOk;
|
|
17233
|
+
if (!Number.isFinite(value)) {
|
|
17234
|
+
if (Number.isFinite(before)) lastOk[tab.key] = before;
|
|
17235
|
+
continue;
|
|
17236
|
+
}
|
|
17237
|
+
lastOk[tab.key] = value;
|
|
17238
|
+
if (Number.isFinite(before) && value > before) {
|
|
17239
|
+
begin.push({ key: `refreshed:${tab.key}`, level: "info", source: tab.label, code: null,
|
|
17240
|
+
text: `Refreshed ${tab.label}`, action: null, at: value, tabs: [tab.key] });
|
|
17241
|
+
}
|
|
17242
|
+
}
|
|
17243
|
+
return { state: { conditions, lastOk }, begin, end, pending };
|
|
17244
|
+
}
|
|
17245
|
+
|
|
17246
|
+
// Read-only: earlier recovery transitions become dimmed context in a new pane.
|
|
17247
|
+
function seedNotificationsFromJournal(log, scope, read = readRecoveryTransitions) {
|
|
17248
|
+
let journal;
|
|
17249
|
+
try { journal = read(scope); } catch { journal = { status: "unavailable", entries: [] }; }
|
|
17250
|
+
if (journal.status === "missing") return;
|
|
17251
|
+
if (journal.status !== "healthy") {
|
|
17252
|
+
log.record({ key: "journal:unavailable", level: "info", source: EARLIER_SESSION_SOURCE,
|
|
17253
|
+
text: "Earlier recovery history unavailable; run gh-glance --doctor" });
|
|
17254
|
+
return;
|
|
17255
|
+
}
|
|
17256
|
+
const open = {};
|
|
17257
|
+
for (const entry of journal.entries) {
|
|
17258
|
+
const key = `journal:${entry.resource}:${entry.code}`;
|
|
17259
|
+
// The same cause re-journaled for a new origin or tab is one episode here.
|
|
17260
|
+
if (open[entry.resource] === key) continue;
|
|
17261
|
+
if (open[entry.resource]) {
|
|
17262
|
+
log.end(open[entry.resource], entry.at);
|
|
17263
|
+
open[entry.resource] = null;
|
|
17264
|
+
}
|
|
17265
|
+
if (entry.code === "clear") continue;
|
|
17266
|
+
log.record({ key, level: noticeLevel({ code: entry.code }), source: EARLIER_SESSION_SOURCE,
|
|
17267
|
+
code: entry.code, text: `${recoveryResourceLabel(entry.resource)}: ${entry.summary}`,
|
|
17268
|
+
action: entry.action, at: entry.firstAt, lastAt: entry.at });
|
|
17269
|
+
open[entry.resource] = key;
|
|
17270
|
+
}
|
|
17271
|
+
}
|
|
17272
|
+
|
|
17273
|
+
// The rows a visible table gives up: an action cause's recovery lines (after
|
|
17274
|
+
// the usual debounce) or an action fetch verdict's one-line remedy. Waits,
|
|
17275
|
+
// failed refreshes and history go to the Notifications view instead.
|
|
17276
|
+
function criticalInlineLines({ cause = null, showCause = false, tabError = null, displayError = "",
|
|
17277
|
+
capacity = 0, nowMs = Date.now(), cols = 80, hasSnapshot } = {}) {
|
|
17278
|
+
let lines = [];
|
|
17279
|
+
let source = null;
|
|
17280
|
+
if (showCause && cause && noticeLevel({ code: cause.code }) === "action") {
|
|
17281
|
+
lines = presentRecovery(cause, { nowMs, cols, hasSnapshot });
|
|
17282
|
+
source = "cause";
|
|
17283
|
+
} else if (tabError && displayError && fetchErrorLevel(tabError) === "action") {
|
|
17284
|
+
lines = [displayError];
|
|
17285
|
+
source = "error";
|
|
17286
|
+
}
|
|
17287
|
+
if (capacity < 1 || lines.length === 0) return { lines: [], source: null };
|
|
17288
|
+
// On a one-row pane the remedy outranks the summary.
|
|
17289
|
+
return { lines: capacity === 1 && lines.length > 1 ? [lines[1]] : lines.slice(0, capacity), source };
|
|
17290
|
+
}
|
|
17291
|
+
|
|
17292
|
+
// `today` is a toDateString() value, or a time on that day.
|
|
17293
|
+
function notificationTime(at, today) {
|
|
17294
|
+
const date = new Date(at);
|
|
17295
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
17296
|
+
const day = date.toDateString() === (typeof today === "string" ? today : new Date(today).toDateString()) ? ""
|
|
17297
|
+
: `${pad(date.getMonth() + 1)}-${pad(date.getDate())} `;
|
|
17298
|
+
return `${day}${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
17299
|
+
}
|
|
17300
|
+
|
|
17301
|
+
// The Notifications body: a GitHub status row, then events newest first. Open
|
|
17302
|
+
// waits show their live retry time (`liveRetry`, keyed by cause code) because
|
|
17303
|
+
// the time a condition began says nothing about when it will clear. Ended,
|
|
17304
|
+
// informational and earlier-session entries are dimmed.
|
|
17305
|
+
function notificationViewLines({ entries, statusLine, nowMs = Date.now(), capacity, offset = 0,
|
|
17306
|
+
liveRetry = {}, profile = ICON_PROFILE === "ascii" ? "ascii" : "unicode" }) {
|
|
17307
|
+
const glyphs = REFRESH_STATUS_GLYPHS[profile] ?? REFRESH_STATUS_GLYPHS.unicode;
|
|
17308
|
+
const glyph = { action: glyphs.failed, problem: glyphs.failed, wait: glyphs.paused, info: glyphs.watching };
|
|
17309
|
+
const today = new Date(nowMs).toDateString();
|
|
17310
|
+
const rows = Math.max(1, capacity);
|
|
17311
|
+
const listRows = Math.max(0, rows - 1);
|
|
17312
|
+
const maxOffset = Math.max(0, entries.length - listRows);
|
|
17313
|
+
const start = Math.min(Math.max(0, offset), maxOffset);
|
|
17314
|
+
const lines = [{ text: statusLine, level: "status", dim: false }];
|
|
17315
|
+
if (entries.length === 0) lines.push({ text: "No events yet", level: "info", dim: true });
|
|
17316
|
+
for (const entry of entries.slice(start, start + listRows)) {
|
|
17317
|
+
const open = entry.endedAt === null;
|
|
17318
|
+
const retryAt = open && entry.code ? liveRetry[entry.code] : null;
|
|
17319
|
+
const retry = Number.isFinite(retryAt) ? `; next ${statusInterval(retryAt, nowMs)}` : "";
|
|
17320
|
+
const action = entry.level === "action" && entry.action ? `; ${entry.action}` : "";
|
|
17321
|
+
const count = entry.count > 1 ? ` x${entry.count}` : "";
|
|
17322
|
+
lines.push({
|
|
17323
|
+
text: `${notificationTime(entry.lastAt, today)} ${glyph[entry.level] ?? glyph.info} ${entry.source}: ${entry.text}${action}${retry}${count}`,
|
|
17324
|
+
level: entry.level,
|
|
17325
|
+
dim: !open || entry.level === "info" || entry.source === EARLIER_SESSION_SOURCE,
|
|
17326
|
+
});
|
|
17327
|
+
}
|
|
17328
|
+
return { lines: lines.slice(0, rows), offset: start, maxOffset, pageRows: Math.max(1, listRows) };
|
|
17329
|
+
}
|
|
17330
|
+
|
|
17331
|
+
// ---------- GitHub platform status ----------
|
|
17332
|
+
|
|
17333
|
+
// githubstatus.com is a separate public host: checking it spends no GitHub API
|
|
17334
|
+
// quota and never goes through `gh` or the governor. It describes github.com
|
|
17335
|
+
// only, so other hosts are not checked. All remote text is display text and
|
|
17336
|
+
// passes safe() before it reaches the log or the screen.
|
|
17337
|
+
const GITHUB_STATUS_URL = "https://www.githubstatus.com/api/v2/summary.json";
|
|
17338
|
+
const GITHUB_STATUS_MAX_BYTES = 256 * 1024;
|
|
17339
|
+
const GITHUB_STATUS_TIMEOUT_MS = 10_000;
|
|
17340
|
+
const GITHUB_STATUS_INTERVAL_MS = 10 * 60_000;
|
|
17341
|
+
const GITHUB_STATUS_BACKOFF_MS = 15 * 60_000;
|
|
17342
|
+
const GITHUB_STATUS_MIN_GAP_MS = 60_000;
|
|
17343
|
+
const GITHUB_STATUS_COMPONENTS = Object.freeze(["API Requests", "Actions", "Issues", "Pull Requests",
|
|
17344
|
+
"Git Operations", "Webhooks"]);
|
|
17345
|
+
const GITHUB_STATUS_ENV = "GH_GLANCE_GITHUB_STATUS";
|
|
17346
|
+
// Words in an untagged incident's name that point at a surface this dashboard
|
|
17347
|
+
// shows. A generic "GitHub services" disruption may touch any of them.
|
|
17348
|
+
const GITHUB_STATUS_RELEVANT_NAME = /\b(?:actions?|runners?|issues?|pull requests?|prs?|requests?|webhooks?|git|api|repos?|repositor(?:y|ies)|dependabot|code scanning|secret scanning|security|github services)\b/i;
|
|
17349
|
+
|
|
17350
|
+
function githubStatusConfig(env = process.env, host = null) {
|
|
17351
|
+
const raw = env[GITHUB_STATUS_ENV];
|
|
17352
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
17353
|
+
if (/^(?:off|0)$/i.test(value)) {
|
|
17354
|
+
return { enabled: false, url: null, reason: `GitHub status checks off (${GITHUB_STATUS_ENV})` };
|
|
17355
|
+
}
|
|
17356
|
+
let url = GITHUB_STATUS_URL;
|
|
17357
|
+
if (value) {
|
|
17358
|
+
let parsed = null;
|
|
17359
|
+
try { parsed = new URL(value); } catch { /* reported below */ }
|
|
17360
|
+
if (!parsed || !["http:", "https:"].includes(parsed.protocol)) {
|
|
17361
|
+
return { enabled: false, url: null, reason: `${GITHUB_STATUS_ENV} value not recognised; GitHub status checks off` };
|
|
17362
|
+
}
|
|
17363
|
+
url = parsed.href;
|
|
17364
|
+
}
|
|
17365
|
+
const selected = normalizeHost(host);
|
|
17366
|
+
if (selected && selected !== "github.com") {
|
|
17367
|
+
return { enabled: false, url: null, reason: `GitHub status not checked for ${safe(selected)}` };
|
|
17368
|
+
}
|
|
17369
|
+
return { enabled: true, url, reason: null };
|
|
17370
|
+
}
|
|
17371
|
+
|
|
17372
|
+
async function readBoundedText(response, limit) {
|
|
17373
|
+
const declared = Number(response.headers.get("content-length"));
|
|
17374
|
+
if (Number.isFinite(declared) && declared > limit) throw new Error("oversize");
|
|
17375
|
+
if (!response.body) return "";
|
|
17376
|
+
const reader = response.body.getReader();
|
|
17377
|
+
const chunks = [];
|
|
17378
|
+
let size = 0;
|
|
17379
|
+
for (;;) {
|
|
17380
|
+
const { done, value } = await reader.read();
|
|
17381
|
+
if (done) break;
|
|
17382
|
+
size += value.byteLength;
|
|
17383
|
+
if (size > limit) {
|
|
17384
|
+
await reader.cancel().catch(() => {});
|
|
17385
|
+
throw new Error("oversize");
|
|
17386
|
+
}
|
|
17387
|
+
chunks.push(value);
|
|
17388
|
+
}
|
|
17389
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
17390
|
+
}
|
|
17391
|
+
|
|
17392
|
+
function parseGitHubStatus(body) {
|
|
17393
|
+
if (!isRecord(body) || !Array.isArray(body.components) ||
|
|
17394
|
+
(body.incidents !== undefined && !Array.isArray(body.incidents))) return null;
|
|
17395
|
+
const text = (value, fallback = "") => typeof value === "string" && value.trim() ? safe(value) : fallback;
|
|
17396
|
+
const components = body.components
|
|
17397
|
+
.filter((component) => isRecord(component) && GITHUB_STATUS_COMPONENTS.includes(component.name))
|
|
17398
|
+
.map((component) => {
|
|
17399
|
+
const status = text(component.status, "unknown");
|
|
17400
|
+
return { name: component.name, status, label: GITHUB_COMPONENT_STATUS[status] ?? status };
|
|
17401
|
+
});
|
|
17402
|
+
// An incident tagged with components, none of them tracked, is about a
|
|
17403
|
+
// product this dashboard does not show and is dropped. About a third of
|
|
17404
|
+
// GitHub's incidents carry no tag (often the first post); those are kept, and
|
|
17405
|
+
// count as relevant only when the name names something this dashboard shows.
|
|
17406
|
+
const incidents = [];
|
|
17407
|
+
for (const [index, incident] of (body.incidents ?? []).entries()) {
|
|
17408
|
+
if (!isRecord(incident)) continue;
|
|
17409
|
+
const tagged = Array.isArray(incident.components) && incident.components.length > 0;
|
|
17410
|
+
const tracked = tagged && incident.components.some((component) =>
|
|
17411
|
+
GITHUB_STATUS_COMPONENTS.includes(component?.name));
|
|
17412
|
+
if (tagged && !tracked) continue;
|
|
17413
|
+
const name = text(incident.name, "Unnamed incident");
|
|
17414
|
+
incidents.push({
|
|
17415
|
+
id: text(incident.id, `incident-${index}`),
|
|
17416
|
+
name,
|
|
17417
|
+
status: text(incident.status, "unknown"),
|
|
17418
|
+
impact: text(incident.impact, "unknown"),
|
|
17419
|
+
updatedAt: text(incident.updated_at, null),
|
|
17420
|
+
relevant: tracked || GITHUB_STATUS_RELEVANT_NAME.test(name),
|
|
17421
|
+
});
|
|
17422
|
+
}
|
|
17423
|
+
return { components, incidents };
|
|
17424
|
+
}
|
|
17425
|
+
|
|
17426
|
+
async function fetchGitHubStatus({ url, etag = null, signal = null, fetchImpl = globalThis.fetch,
|
|
17427
|
+
timeoutMs = GITHUB_STATUS_TIMEOUT_MS } = {}) {
|
|
17428
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
17429
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
17430
|
+
try {
|
|
17431
|
+
const response = await fetchImpl(url, { signal: combined, redirect: "follow",
|
|
17432
|
+
headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) } });
|
|
17433
|
+
if (response.status === 304) return { status: "not-modified" };
|
|
17434
|
+
if (!response.ok) return { status: "unavailable", reason: `HTTP ${response.status}` };
|
|
17435
|
+
const summary = parseGitHubStatus(JSON.parse(await readBoundedText(response, GITHUB_STATUS_MAX_BYTES)));
|
|
17436
|
+
if (!summary) return { status: "unavailable", reason: "unexpected response" };
|
|
17437
|
+
const tag = response.headers.get("etag");
|
|
17438
|
+
return { status: "ok", summary, etag: typeof tag === "string" && tag.length <= 256 ? tag : null };
|
|
17439
|
+
} catch (error) {
|
|
17440
|
+
return { status: "unavailable", reason: error?.name === "TimeoutError" ? "timeout" : "unreachable" };
|
|
17441
|
+
}
|
|
17442
|
+
}
|
|
17443
|
+
|
|
17444
|
+
const GITHUB_COMPONENT_STATUS = Object.freeze({ degraded_performance: "degraded performance",
|
|
17445
|
+
partial_outage: "partial outage", major_outage: "major outage", under_maintenance: "under maintenance" });
|
|
17446
|
+
|
|
17447
|
+
// `state` is a monitor's current(): the last good summary, kept separately from
|
|
17448
|
+
// whether the latest check reached the page.
|
|
17449
|
+
function githubStatusLine(state, nowMs = Date.now()) {
|
|
17450
|
+
if (!state?.enabled) return state?.reason ?? "GitHub status checks off";
|
|
17451
|
+
if (state.unavailable) {
|
|
17452
|
+
return Number.isFinite(state.nextAt)
|
|
17453
|
+
? `GitHub status unavailable; next check ${notificationTime(state.nextAt, nowMs)}`
|
|
17454
|
+
: "GitHub status unavailable";
|
|
17455
|
+
}
|
|
17456
|
+
if (!state.summary) return "GitHub status: not checked yet";
|
|
17457
|
+
const { components } = state.summary;
|
|
17458
|
+
const incidents = state.summary.incidents.filter((incident) => incident.relevant !== false);
|
|
17459
|
+
const others = state.summary.incidents.length - incidents.length;
|
|
17460
|
+
if (incidents.length > 0) {
|
|
17461
|
+
const more = incidents.length > 1 ? ` (+${incidents.length - 1} more)` : "";
|
|
17462
|
+
return `GitHub incident: ${incidents[0].name}${more}`;
|
|
17463
|
+
}
|
|
17464
|
+
const degraded = components.filter((component) => component.status !== "operational");
|
|
17465
|
+
if (degraded.length > 0) {
|
|
17466
|
+
return `GitHub: ${degraded.map((component) => `${component.name} ${component.label}`).join(", ")}`;
|
|
17467
|
+
}
|
|
17468
|
+
if (others > 0) {
|
|
17469
|
+
return `GitHub status: no incident affecting this dashboard (${others} ${others === 1 ? "other" : "others"})`;
|
|
17470
|
+
}
|
|
17471
|
+
return "GitHub status: All systems operational";
|
|
17472
|
+
}
|
|
17473
|
+
|
|
17474
|
+
// Polls the status page on a slow cadence and turns changes into log entries:
|
|
17475
|
+
// an incident or a degraded tracked component is a problem, its end is a
|
|
17476
|
+
// "Cleared" line, and an unreachable status page is one info entry per
|
|
17477
|
+
// episode. `checkNow` (view open, r, a failing tab) runs at most once a minute.
|
|
17478
|
+
function createGitHubStatusMonitor({ config, log, fetchImpl = globalThis.fetch, now = Date.now,
|
|
17479
|
+
setTimer = setTimeout, clearTimer = clearTimeout, onChange = () => {} }) {
|
|
17480
|
+
let summary = null;
|
|
17481
|
+
let etag = null;
|
|
17482
|
+
let lastAttemptAt = -Infinity;
|
|
17483
|
+
let nextAt = null;
|
|
17484
|
+
let timer = null;
|
|
17485
|
+
let stopped = !config?.enabled;
|
|
17486
|
+
let inFlight = null;
|
|
17487
|
+
let controller = null;
|
|
17488
|
+
let unavailable = false;
|
|
17489
|
+
let open = new Map(); // log key -> { text, level } of each ongoing incident or outage
|
|
17490
|
+
|
|
17491
|
+
function schedule(delay) {
|
|
17492
|
+
if (stopped) return;
|
|
17493
|
+
if (timer !== null) clearTimer(timer);
|
|
17494
|
+
nextAt = now() + delay;
|
|
17495
|
+
// The promise is returned for injected test clocks; check() never rejects.
|
|
17496
|
+
timer = setTimer(() => { timer = null; return check(); }, delay);
|
|
17497
|
+
timer?.unref?.();
|
|
17498
|
+
}
|
|
17499
|
+
|
|
17500
|
+
function apply(next, at) {
|
|
17501
|
+
// An incident that may not touch this dashboard is listed as information:
|
|
17502
|
+
// it neither turns the badge red nor counts as an outage.
|
|
17503
|
+
const conditions = new Map([
|
|
17504
|
+
...next.incidents.map((incident) => [`github:incident:${incident.id}`, {
|
|
17505
|
+
text: `${incident.name} (${incident.status}, ${incident.impact} impact)`,
|
|
17506
|
+
level: incident.relevant === false ? "info" : "problem" }]),
|
|
17507
|
+
...next.components.filter((component) => component.status !== "operational")
|
|
17508
|
+
.map((component) => [`github:component:${component.name}`,
|
|
17509
|
+
{ text: `${component.name}: ${component.label}`, level: "problem" }]),
|
|
17510
|
+
]);
|
|
17511
|
+
// A changed text is an update of the same open entry, not a new one.
|
|
17512
|
+
for (const [key, { text, level }] of conditions) {
|
|
17513
|
+
if (open.get(key)?.text !== text) log.record({ key, level, source: "GitHub status", text, at });
|
|
17514
|
+
}
|
|
17515
|
+
for (const key of open.keys()) if (!conditions.has(key)) log.end(key, at);
|
|
17516
|
+
open = conditions;
|
|
17517
|
+
}
|
|
17518
|
+
|
|
17519
|
+
async function check() {
|
|
17520
|
+
if (stopped || inFlight) return inFlight;
|
|
17521
|
+
lastAttemptAt = now();
|
|
17522
|
+
controller = new AbortController();
|
|
17523
|
+
inFlight = (async () => {
|
|
17524
|
+
const outcome = await fetchGitHubStatus({ url: config.url, etag, signal: controller.signal, fetchImpl });
|
|
17525
|
+
if (stopped) return;
|
|
17526
|
+
const at = now();
|
|
17527
|
+
if (outcome.status === "unavailable") {
|
|
17528
|
+
if (!unavailable) {
|
|
17529
|
+
log.record({ key: "github:unavailable", level: "info", source: "GitHub status",
|
|
17530
|
+
text: "GitHub status page unavailable", at });
|
|
17531
|
+
}
|
|
17532
|
+
unavailable = true;
|
|
17533
|
+
schedule(GITHUB_STATUS_BACKOFF_MS);
|
|
17534
|
+
} else if (outcome.status === "not-modified" && !summary) {
|
|
17535
|
+
// Nothing cached to reuse: drop the validator and ask again in a minute.
|
|
17536
|
+
etag = null;
|
|
17537
|
+
schedule(GITHUB_STATUS_MIN_GAP_MS);
|
|
17538
|
+
} else {
|
|
17539
|
+
if (unavailable) log.end("github:unavailable", at);
|
|
17540
|
+
unavailable = false;
|
|
17541
|
+
if (outcome.status === "ok") {
|
|
17542
|
+
etag = outcome.etag;
|
|
17543
|
+
summary = outcome.summary;
|
|
17544
|
+
apply(summary, at);
|
|
17545
|
+
}
|
|
17546
|
+
schedule(GITHUB_STATUS_INTERVAL_MS);
|
|
17547
|
+
}
|
|
17548
|
+
try { onChange(); } catch { /* a render callback must not break polling */ }
|
|
17549
|
+
})().finally(() => {
|
|
17550
|
+
inFlight = null;
|
|
17551
|
+
controller = null;
|
|
17552
|
+
});
|
|
17553
|
+
return inFlight;
|
|
17554
|
+
}
|
|
17555
|
+
|
|
17556
|
+
const ready = stopped ? Promise.resolve() : check();
|
|
17557
|
+
return {
|
|
17558
|
+
ready,
|
|
17559
|
+
async checkNow() {
|
|
17560
|
+
if (stopped || inFlight || now() - lastAttemptAt < GITHUB_STATUS_MIN_GAP_MS) return false;
|
|
17561
|
+
await check();
|
|
17562
|
+
return true;
|
|
17563
|
+
},
|
|
17564
|
+
stop() {
|
|
17565
|
+
stopped = true;
|
|
17566
|
+
controller?.abort();
|
|
17567
|
+
if (timer !== null) clearTimer(timer);
|
|
17568
|
+
timer = null;
|
|
17569
|
+
},
|
|
17570
|
+
current: () => ({ enabled: Boolean(config?.enabled), reason: config?.reason ?? null, summary,
|
|
17571
|
+
unavailable, nextAt, outage: [...open.values()].some((condition) => condition.level === "problem") }),
|
|
17572
|
+
};
|
|
17573
|
+
}
|
|
17574
|
+
|
|
16982
17575
|
function recoveryLedgerSummary(scope, resource, nowMs = Date.now()) {
|
|
16983
17576
|
if (!scope?.path || !RATE_RESOURCES.includes(resource)) return null;
|
|
16984
17577
|
const loaded = readGovernorState(scope.path, nowMs, { persistMigration: false });
|
|
@@ -17386,63 +17979,90 @@ const MIN_TABLE_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.header)));
|
|
|
17386
17979
|
// status line survive at any width a terminal is realistically set to.
|
|
17387
17980
|
const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactHeader)));
|
|
17388
17981
|
|
|
17389
|
-
// The tab bar is laid out independently of the table
|
|
17390
|
-
//
|
|
17391
|
-
//
|
|
17392
|
-
//
|
|
17393
|
-
|
|
17982
|
+
// The tab bar is laid out independently of the table. Full labels are used
|
|
17983
|
+
// whenever they fit the measured bar (~97 columns with counts on every tab, less
|
|
17984
|
+
// with fewer); short labels need ~76. Once the bar has dropped to short labels,
|
|
17985
|
+
// full ones return only with this many spare columns, so a pane parked on the
|
|
17986
|
+
// boundary, or a count gaining a digit, does not flip labels back and forth and
|
|
17987
|
+
// defeat the unchanged-frame optimization. Below the short width the bar drops
|
|
17988
|
+
// names and keeps digits and counts (see tabBarEntries).
|
|
17394
17989
|
const TAB_LABEL_HYSTERESIS = 4;
|
|
17990
|
+
const NOTIFICATIONS_LABEL = Object.freeze({ full: "Notifications", short: "Status" });
|
|
17395
17991
|
|
|
17396
17992
|
function tabFailureSuffix({ count, failed, brokenCI }) {
|
|
17397
17993
|
if (count == null) return failed ? " x" : "";
|
|
17398
17994
|
return ` (${count}${brokenCI ? "!" : ""}${failed ? "x" : ""})`;
|
|
17399
17995
|
}
|
|
17400
17996
|
|
|
17401
|
-
function
|
|
17997
|
+
function tabBarWidth(entries) {
|
|
17998
|
+
return entries.reduce((sum, entry) => sum + entry.label.length + entry.margin, 0);
|
|
17999
|
+
}
|
|
18000
|
+
|
|
18001
|
+
// Four data sections plus Notifications on one row. The widest tier that fits
|
|
18002
|
+
// `cols` wins: full names, short names, digits with counts, the same without
|
|
18003
|
+
// gaps, then bare digits that keep only the Notifications badge -- so every
|
|
18004
|
+
// section key stays visible, and the row never wraps, down to a ~20-column pane.
|
|
18005
|
+
function tabBarEntries({ cols, counts, brokenCI, firstLoad, failed, spin, preferShort = false, activeIndex,
|
|
18006
|
+
notifications = { unread: 0, tone: "none" } }) {
|
|
18007
|
+
const sections = TABS.map((tab) => {
|
|
18008
|
+
const count = counts[tab.key];
|
|
18009
|
+
// A tab that has never resolved shows the spinner where its count will
|
|
18010
|
+
// go, so the first load reads as "working" rather than "empty".
|
|
18011
|
+
const suffix = count == null && firstLoad[tab.key] ? ` ${spin}`
|
|
18012
|
+
: tabFailureSuffix({ count, failed: failed[tab.key], brokenCI: count == null ? false : brokenCI[tab.key] });
|
|
18013
|
+
// A tab whose fetches are failing kept rendering its last good count at
|
|
18014
|
+
// full confidence, and the error banner is only visible on the tab you
|
|
18015
|
+
// have selected -- so a background tab could fail for an hour with
|
|
18016
|
+
// nothing on screen to say so.
|
|
18017
|
+
return { key: tab.key, full: tab.label, short: tab.short, suffix,
|
|
18018
|
+
color: failed[tab.key] ? "redBright" : undefined, quiet: !failed[tab.key] };
|
|
18019
|
+
});
|
|
18020
|
+
const { unread, tone } = notifications;
|
|
18021
|
+
sections.push({ key: "notifications", ...NOTIFICATIONS_LABEL,
|
|
18022
|
+
suffix: unread > 0 ? ` (${unread})` : "",
|
|
18023
|
+
color: tone === "red" ? "redBright" : tone === "yellow" ? ATTENTION : undefined,
|
|
18024
|
+
quiet: tone !== "red" && tone !== "yellow" });
|
|
18025
|
+
const build = (tier) => sections.map((section, index) => {
|
|
18026
|
+
const active = index === activeIndex;
|
|
18027
|
+
const named = tier === "full" || tier === "short";
|
|
18028
|
+
const suffix = tier === "digits" && section.key !== "notifications" ? "" : section.suffix;
|
|
18029
|
+
const body = named ? `${index + 1}:${section[tier]}${suffix}` : `${index + 1}${suffix.trimStart()}`;
|
|
18030
|
+
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
18031
|
+
// dumb terminal), which left no indication at all of which tab was
|
|
18032
|
+
// selected. Brackets survive; the padding they replace keeps the width
|
|
18033
|
+
// identical, so the bar does not shift between the two modes.
|
|
18034
|
+
return { key: section.key, active, label: active ? `[${body}]` : ` ${body} `,
|
|
18035
|
+
margin: index === sections.length - 1 || tier === "tight" || tier === "digits" ? 0 : tier === "full" ? 2 : 1,
|
|
18036
|
+
color: section.color, dim: !active && section.quiet, tier };
|
|
18037
|
+
});
|
|
18038
|
+
const full = build("full");
|
|
18039
|
+
if (tabBarWidth(full) + (preferShort ? TAB_LABEL_HYSTERESIS : 0) <= cols) return full;
|
|
18040
|
+
for (const tier of ["short", "compact", "tight"]) {
|
|
18041
|
+
const entries = build(tier);
|
|
18042
|
+
if (tabBarWidth(entries) <= cols) return entries;
|
|
18043
|
+
}
|
|
18044
|
+
return build("digits");
|
|
18045
|
+
}
|
|
18046
|
+
|
|
18047
|
+
function TabBar({ entries }) {
|
|
17402
18048
|
return e(
|
|
17403
18049
|
Box,
|
|
17404
18050
|
{ flexDirection: "row" },
|
|
17405
|
-
...
|
|
17406
|
-
|
|
17407
|
-
|
|
17408
|
-
|
|
17409
|
-
|
|
17410
|
-
|
|
17411
|
-
|
|
17412
|
-
|
|
17413
|
-
|
|
17414
|
-
|
|
17415
|
-
:
|
|
17416
|
-
|
|
17417
|
-
|
|
17418
|
-
|
|
17419
|
-
|
|
17420
|
-
const name = useShort ? tab.short : tab.label;
|
|
17421
|
-
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
17422
|
-
// dumb terminal), which left no indication at all of which tab was
|
|
17423
|
-
// selected. Brackets survive; the padding they replace keeps the width
|
|
17424
|
-
// identical, so the bar does not shift between the two modes.
|
|
17425
|
-
const label = active ? `[${i + 1}:${name}${suffix}]` : ` ${i + 1}:${name}${suffix} `;
|
|
17426
|
-
return e(
|
|
17427
|
-
Box,
|
|
17428
|
-
{ key: tab.key, marginRight: 2 },
|
|
17429
|
-
e(
|
|
17430
|
-
Text,
|
|
17431
|
-
{
|
|
17432
|
-
bold: active,
|
|
17433
|
-
inverse: active,
|
|
17434
|
-
// A tab whose fetches are failing kept rendering its last good
|
|
17435
|
-
// count at full confidence, and the error banner is only visible on
|
|
17436
|
-
// the tab you have selected -- so a background tab could fail for an
|
|
17437
|
-
// hour with nothing on screen to say so.
|
|
17438
|
-
color: failed[tab.key] ? "redBright" : undefined,
|
|
17439
|
-
dimColor: !active && !failed[tab.key],
|
|
17440
|
-
wrap: "truncate-end",
|
|
17441
|
-
},
|
|
17442
|
-
label,
|
|
17443
|
-
),
|
|
17444
|
-
);
|
|
17445
|
-
}),
|
|
18051
|
+
...entries.map((entry) => e(
|
|
18052
|
+
Box,
|
|
18053
|
+
{ key: entry.key, marginRight: entry.margin },
|
|
18054
|
+
e(
|
|
18055
|
+
Text,
|
|
18056
|
+
{
|
|
18057
|
+
bold: entry.active,
|
|
18058
|
+
inverse: entry.active,
|
|
18059
|
+
color: entry.color,
|
|
18060
|
+
dimColor: entry.dim,
|
|
18061
|
+
wrap: "truncate-end",
|
|
18062
|
+
},
|
|
18063
|
+
entry.label,
|
|
18064
|
+
),
|
|
18065
|
+
)),
|
|
17446
18066
|
);
|
|
17447
18067
|
}
|
|
17448
18068
|
|
|
@@ -17926,7 +18546,7 @@ function StatusBar({
|
|
|
17926
18546
|
|
|
17927
18547
|
// ---------- Layout ----------
|
|
17928
18548
|
|
|
17929
|
-
// Terminal size
|
|
18549
|
+
// Terminal size.
|
|
17930
18550
|
//
|
|
17931
18551
|
// The usableSize guard is applied on every read path, not just the first: pty
|
|
17932
18552
|
// wrappers and a terminal mid-resize report 0 or undefined, and taking either
|
|
@@ -17936,7 +18556,6 @@ function useTerminalSize(stdout) {
|
|
|
17936
18556
|
const [size, setSize] = useState(() => ({
|
|
17937
18557
|
rows: usableSize(stdout?.rows, DEFAULT_ROWS),
|
|
17938
18558
|
cols: usableSize(stdout?.columns, DEFAULT_COLS),
|
|
17939
|
-
useShortLabels: usableSize(stdout?.columns, DEFAULT_COLS) < TAB_LABEL_FULL_WIDTH,
|
|
17940
18559
|
}));
|
|
17941
18560
|
|
|
17942
18561
|
useEffect(() => {
|
|
@@ -17944,21 +18563,9 @@ function useTerminalSize(stdout) {
|
|
|
17944
18563
|
function onResize() {
|
|
17945
18564
|
const cols = usableSize(stdout.columns, DEFAULT_COLS);
|
|
17946
18565
|
const rows = usableSize(stdout.rows, DEFAULT_ROWS);
|
|
17947
|
-
|
|
17948
|
-
|
|
17949
|
-
|
|
17950
|
-
// boundary does not emit a different frame on every resize event.
|
|
17951
|
-
const useShortLabels = previous.useShortLabels
|
|
17952
|
-
? cols < TAB_LABEL_FULL_WIDTH + TAB_LABEL_HYSTERESIS
|
|
17953
|
-
: cols < TAB_LABEL_FULL_WIDTH;
|
|
17954
|
-
// Same object when nothing moved, so a resize event that changes
|
|
17955
|
-
// nothing cannot cost a redraw.
|
|
17956
|
-
return previous.rows === rows &&
|
|
17957
|
-
previous.cols === cols &&
|
|
17958
|
-
previous.useShortLabels === useShortLabels
|
|
17959
|
-
? previous
|
|
17960
|
-
: { rows, cols, useShortLabels };
|
|
17961
|
-
});
|
|
18566
|
+
// Same object when nothing moved, so a resize event that changes
|
|
18567
|
+
// nothing cannot cost a redraw.
|
|
18568
|
+
setSize((previous) => previous.rows === rows && previous.cols === cols ? previous : { rows, cols });
|
|
17962
18569
|
}
|
|
17963
18570
|
stdout.on("resize", onResize);
|
|
17964
18571
|
return () => stdout.off("resize", onResize);
|
|
@@ -18639,6 +19246,108 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18639
19246
|
const { isRawModeSupported } = useStdin();
|
|
18640
19247
|
const { exit, suspendTerminal } = useApp();
|
|
18641
19248
|
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
19249
|
+
// Notifications is a view, not a data tab: `activeIndex` keeps naming the
|
|
19250
|
+
// last data tab, so polling, cache and per-tab state never see a fifth key.
|
|
19251
|
+
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
|
19252
|
+
const notificationsOpenRef = useRef(notificationsOpen);
|
|
19253
|
+
notificationsOpenRef.current = notificationsOpen;
|
|
19254
|
+
const [notificationsOffset, setNotificationsOffset] = useState(0);
|
|
19255
|
+
const notificationLogRef = useRef(null);
|
|
19256
|
+
notificationLogRef.current ??= createNotificationLog();
|
|
19257
|
+
// Only the badge lives in state, and it changes only when the unread count or
|
|
19258
|
+
// tone does: routine refreshes never re-render a closed view, which keeps an
|
|
19259
|
+
// idle dashboard's frames byte-identical.
|
|
19260
|
+
const [notificationBadge, setNotificationBadge] = useState(() => notificationLogRef.current.summary());
|
|
19261
|
+
const notificationBadgeRef = useRef(notificationBadge);
|
|
19262
|
+
const [, setNotificationVersion] = useState(0);
|
|
19263
|
+
const notificationSnapshotRef = useRef(null);
|
|
19264
|
+
const notificationDiffRef = useRef(null);
|
|
19265
|
+
const notificationTimerRef = useRef(null);
|
|
19266
|
+
const syncNotificationsRef = useRef(null);
|
|
19267
|
+
const notificationInputsRef = useRef(null);
|
|
19268
|
+
syncNotificationsRef.current = ({ force = false } = {}) => {
|
|
19269
|
+
const log = notificationLogRef.current;
|
|
19270
|
+
const nowMs = Date.now();
|
|
19271
|
+
// Spinner and clock renders change none of the inputs; skip their diff.
|
|
19272
|
+
const inputs = notificationSnapshotRef.current.inputs;
|
|
19273
|
+
const previousInputs = notificationInputsRef.current;
|
|
19274
|
+
const unchanged = !force && previousInputs?.length === inputs.length &&
|
|
19275
|
+
inputs.every((value, index) => Object.is(value, previousInputs[index]));
|
|
19276
|
+
notificationInputsRef.current = inputs;
|
|
19277
|
+
const result = unchanged ? { begin: [], end: [], pending: notificationTimerRef.current?.due ?? null }
|
|
19278
|
+
: diffNotificationSnapshots(notificationDiffRef.current, notificationSnapshotRef.current, nowMs);
|
|
19279
|
+
if (!unchanged) notificationDiffRef.current = result.state;
|
|
19280
|
+
for (const event of result.begin) log.record(event);
|
|
19281
|
+
for (const key of result.end) log.end(key, nowMs);
|
|
19282
|
+
// A condition can become due with nothing else re-rendering the pane.
|
|
19283
|
+
if ((notificationTimerRef.current?.due ?? null) !== result.pending) {
|
|
19284
|
+
clearTimeout(notificationTimerRef.current?.id);
|
|
19285
|
+
notificationTimerRef.current = result.pending === null ? null : {
|
|
19286
|
+
due: result.pending,
|
|
19287
|
+
id: setTimeout(() => {
|
|
19288
|
+
notificationTimerRef.current = null;
|
|
19289
|
+
syncNotificationsRef.current?.({ force: true });
|
|
19290
|
+
}, Math.max(0, result.pending - nowMs)),
|
|
19291
|
+
};
|
|
19292
|
+
notificationTimerRef.current?.id.unref?.();
|
|
19293
|
+
}
|
|
19294
|
+
if (notificationsOpenRef.current && !showHelpRef.current) {
|
|
19295
|
+
log.markSeen();
|
|
19296
|
+
if (result.begin.length > 0 || result.end.length > 0) setNotificationVersion((version) => version + 1);
|
|
19297
|
+
}
|
|
19298
|
+
// A failing tab is the moment GitHub's own status is worth a look.
|
|
19299
|
+
if (result.begin.some((event) => event.level === "problem" || event.code === "network-outage")) {
|
|
19300
|
+
void githubStatusRef.current?.checkNow();
|
|
19301
|
+
}
|
|
19302
|
+
const badge = log.summary();
|
|
19303
|
+
if (badge.unread !== notificationBadgeRef.current.unread || badge.tone !== notificationBadgeRef.current.tone) {
|
|
19304
|
+
notificationBadgeRef.current = badge;
|
|
19305
|
+
setNotificationBadge(badge);
|
|
19306
|
+
}
|
|
19307
|
+
};
|
|
19308
|
+
useEffect(() => { syncNotificationsRef.current(); });
|
|
19309
|
+
const notificationPageRef = useRef({ pageRows: 1, maxOffset: 0 });
|
|
19310
|
+
// GitHub platform status: started only here, inside the mounted dashboard,
|
|
19311
|
+
// so --doctor, --version, --help and other non-TUI paths never request it.
|
|
19312
|
+
const githubStatusRef = useRef(null);
|
|
19313
|
+
const githubStatusConfigRef = useRef(null);
|
|
19314
|
+
githubStatusConfigRef.current ??= githubStatusConfig(process.env, effectiveRuntimeHost());
|
|
19315
|
+
const [githubStatus, setGithubStatus] = useState(() => ({ enabled: githubStatusConfigRef.current.enabled,
|
|
19316
|
+
reason: githubStatusConfigRef.current.reason, summary: null, unavailable: false, nextAt: null, outage: false }));
|
|
19317
|
+
useEffect(() => {
|
|
19318
|
+
const monitor = createGitHubStatusMonitor({ config: githubStatusConfigRef.current,
|
|
19319
|
+
log: notificationLogRef.current,
|
|
19320
|
+
onChange: () => setGithubStatus(monitor.current()) });
|
|
19321
|
+
githubStatusRef.current = monitor;
|
|
19322
|
+
return () => {
|
|
19323
|
+
monitor.stop();
|
|
19324
|
+
githubStatusRef.current = null;
|
|
19325
|
+
};
|
|
19326
|
+
}, []);
|
|
19327
|
+
const openNotifications = () => {
|
|
19328
|
+
notificationLogRef.current.markSeen();
|
|
19329
|
+
setNotificationsOffset(0);
|
|
19330
|
+
setNotificationsOpen(true);
|
|
19331
|
+
void githubStatusRef.current?.checkNow();
|
|
19332
|
+
};
|
|
19333
|
+
// Five positions: the four data tabs, then Notifications.
|
|
19334
|
+
const selectPosition = (position) => {
|
|
19335
|
+
if (position === TABS.length) {
|
|
19336
|
+
if (!notificationsOpenRef.current) openNotifications();
|
|
19337
|
+
return;
|
|
19338
|
+
}
|
|
19339
|
+
setNotificationsOpen(false);
|
|
19340
|
+
setActiveIndex(position);
|
|
19341
|
+
};
|
|
19342
|
+
const stepPosition = (direction) => {
|
|
19343
|
+
const positions = TABS.length + 1;
|
|
19344
|
+
const current = notificationsOpenRef.current ? TABS.length : activeIndexRef.current;
|
|
19345
|
+
selectPosition((current + direction + positions) % positions);
|
|
19346
|
+
};
|
|
19347
|
+
useEffect(() => () => {
|
|
19348
|
+
clearTimeout(notificationTimerRef.current?.id);
|
|
19349
|
+
syncNotificationsRef.current = null;
|
|
19350
|
+
}, []);
|
|
18642
19351
|
const [preferencePath] = useState(() => widthPreferencesPath());
|
|
18643
19352
|
const [loadedWidthPreferences] = useState(() => loadWidthPreferences(preferencePath));
|
|
18644
19353
|
const [widthOverrides, setWidthOverrides] = useState(() => loadedWidthPreferences.preferences);
|
|
@@ -18917,7 +19626,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18917
19626
|
jevOperationRef.current=null;runtimeJevSession?.clear();setFeature(current=>current?{...current,semantic:{message:'Jev results expired; J Run again.'}}:null);
|
|
18918
19627
|
}
|
|
18919
19628
|
},[feature,now]);
|
|
18920
|
-
const { rows, cols
|
|
19629
|
+
const { rows, cols } = useTerminalSize(stdout);
|
|
19630
|
+
// Whether the last frame's tab bar used short labels: tabBarEntries needs it
|
|
19631
|
+
// for hysteresis, and it depends on counts as well as width, so it is
|
|
19632
|
+
// tracked per frame rather than per resize.
|
|
19633
|
+
const shortTabLabelsRef = useRef(false);
|
|
18921
19634
|
const [frame, setFrame] = useState(0);
|
|
18922
19635
|
// Per tab, so switching away and back keeps your place. Keyed by item, and
|
|
18923
19636
|
// both are plain state: they change only on a keypress, so an idle repo still
|
|
@@ -19033,14 +19746,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19033
19746
|
const noticeCause = activeRecovery ?? (noticeReason ? recoveryCause({
|
|
19034
19747
|
reason: noticeReason === "other" ? "network-outage" : noticeReason,
|
|
19035
19748
|
resource: noticeResource, sourceAt: noticeSourceAt,
|
|
19036
|
-
retryAt: activeGovernorDecision
|
|
19037
|
-
activeGovernorDecision?.notBefore ?? null,
|
|
19749
|
+
retryAt: decisionRetryAt(activeGovernorDecision),
|
|
19038
19750
|
origin: tabError ? "source" : "coordination", tab: tabError ? tab.key : null,
|
|
19039
19751
|
}) : null);
|
|
19040
|
-
const recoveryLines = showCoordinationNotice && noticeCause
|
|
19041
|
-
? presentRecovery(noticeCause, { nowMs: now.getTime(), cols, hasSnapshot: items !== null })
|
|
19042
|
-
: [!remoteSetup && displayError ? displayError : ""];
|
|
19043
|
-
const noticeTone = showCoordinationNotice ? ATTENTION : displayError ? ERROR_TEXT : undefined;
|
|
19044
19752
|
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
19045
19753
|
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
19046
19754
|
// every pane is plausible and wrong. Threshold-gated and minute-granular on
|
|
@@ -19138,34 +19846,74 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19138
19846
|
: ['CI evidence',...ciLines].flatMap(line=>wrapPanelText(line,Math.max(1,cols-5))).slice(0,Math.max(1,rows-6))
|
|
19139
19847
|
: contextualPanelLayout(presentation, { cols, rows });
|
|
19140
19848
|
|
|
19849
|
+
// One snapshot per render feeds the notification history; diffing it in an
|
|
19850
|
+
// effect avoids instrumenting every error, recovery and Security-note writer.
|
|
19851
|
+
const notificationDisconnected = statusDisconnected ? runtime.connect === null ? "identity-unavailable"
|
|
19852
|
+
: activeGovernorDecision?.reason === "app-auth" ? "app-auth" : "disconnected" : null;
|
|
19853
|
+
notificationSnapshotRef.current = {
|
|
19854
|
+
// Everything the snapshot is built from, compared by identity before diffing.
|
|
19855
|
+
inputs: [errors, acquisitionFailures, governorDecisions, recoveryCauses, securityNotes, failureContext,
|
|
19856
|
+
notificationDisconnected, ...TABS.map(({ key }) => lastOkRef.current[key] ?? null)],
|
|
19857
|
+
tabs: Object.fromEntries(TABS.map(({ key }) => {
|
|
19858
|
+
const decision = governorDecisions[key];
|
|
19859
|
+
return [key, { lastOk: lastOkRef.current[key] ?? null, error: errors[key],
|
|
19860
|
+
failure: acquisitionFailures[key] ?? null,
|
|
19861
|
+
paused: decision?.mode === "paused" || decision?.coordinationError ? decision.reason ?? null : null }];
|
|
19862
|
+
})),
|
|
19863
|
+
recovery: recoveryCauses,
|
|
19864
|
+
securityNotes,
|
|
19865
|
+
failureContext,
|
|
19866
|
+
disconnected: notificationDisconnected,
|
|
19867
|
+
};
|
|
19868
|
+
|
|
19141
19869
|
const tableVisible = !ciView && !attentionView && Array.isArray(items) && items.length > 0;
|
|
19142
19870
|
const tableNoticeCapacity = Math.max(0, rows - 9);
|
|
19143
|
-
|
|
19144
|
-
|
|
19145
|
-
|
|
19146
|
-
|
|
19147
|
-
|
|
19148
|
-
|
|
19149
|
-
: showCoordinationNotice && noticeCause
|
|
19150
|
-
? [noticeCause.summary, shortAction, presentation.sourceCheck]
|
|
19151
|
-
: [shortAction, presentation.sourceCheck, presentation.title]
|
|
19152
|
-
: [presentation.title, presentation.sourceCheck, ...recoveryLines.filter(Boolean)]
|
|
19153
|
-
: recoveryLines;
|
|
19871
|
+
// Only what needs the user's hand takes rows from a visible table; every
|
|
19872
|
+
// other status event is in the Notifications view.
|
|
19873
|
+
const critical = criticalInlineLines({ cause: noticeCause, showCause: showCoordinationNotice,
|
|
19874
|
+
tabError: remoteSetup ? null : tabError, displayError, capacity: tableNoticeCapacity,
|
|
19875
|
+
nowMs: now.getTime(), cols, hasSnapshot: items !== null });
|
|
19876
|
+
const noticeTone = critical.source === "cause" ? ATTENTION : critical.source === "error" ? ERROR_TEXT : undefined;
|
|
19154
19877
|
const featureLines = focus ? [presentation.title, ...(focus.error ? [focus.error] : []),
|
|
19155
19878
|
...focusDisclosureLines(focusMessage,focus.semantic?.message).flatMap(line=>line.split(/(?<=\.) /u)), ...featureKeyLines(focus)]
|
|
19156
19879
|
.flatMap(line=>wrapPanelText(line, Math.max(1,cols-5))) : [];
|
|
19157
|
-
const noticeLines = [...new Set([...(focus ? featureLines : []), ...
|
|
19880
|
+
const noticeLines = [...new Set([...(focus ? featureLines : []), ...critical.lines,
|
|
19158
19881
|
...(setupView?.title ? [setupView.title, setupView.action, ...setupView.notes] : []),
|
|
19159
|
-
...
|
|
19160
|
-
const
|
|
19161
|
-
? securityLines.slice(0, Math.max(0, tableNoticeCapacity - noticeLines.length)) : [];
|
|
19162
|
-
const extraLines = !showHelp && !remoteSetup && tableVisible
|
|
19163
|
-
? noticeLines.length + visibleSecurityLines.length : 0;
|
|
19882
|
+
...(!focus && featureNotice ? [featureNotice] : [])])].filter(Boolean).slice(0, tableNoticeCapacity);
|
|
19883
|
+
const extraLines = !showHelp && !remoteSetup && tableVisible ? noticeLines.length : 0;
|
|
19164
19884
|
// Tab strip, divider, frame edges, footer and terminal guard use six rows.
|
|
19165
19885
|
// Table headers alone need two more. The same budget drives navigation.
|
|
19166
19886
|
const bodyRows = Math.max(1, rows - 6 - (tableVisible && !showHelp ? 2 : 0) - extraLines);
|
|
19887
|
+
const notificationRows = Math.max(1, rows - 6);
|
|
19888
|
+
const notificationStatusLine = notificationsOpen ? githubStatusLine(githubStatus, now.getTime()) : "";
|
|
19889
|
+
let notificationView = null;
|
|
19890
|
+
if (notificationsOpen) {
|
|
19891
|
+
// Open waits show when they retry next, from the live cause or hold.
|
|
19892
|
+
const liveRetry = {};
|
|
19893
|
+
for (const cause of Object.values(recoveryCauses)) {
|
|
19894
|
+
if (cause?.code && Number.isFinite(cause.retryAt)) liveRetry[cause.code] = cause.retryAt;
|
|
19895
|
+
}
|
|
19896
|
+
for (const decision of Object.values(governorDecisions)) {
|
|
19897
|
+
const retryAt = decisionRetryAt(decision);
|
|
19898
|
+
if (decision?.reason && Number.isFinite(retryAt)) liveRetry[decision.reason] ??= retryAt;
|
|
19899
|
+
}
|
|
19900
|
+
notificationView = notificationViewLines({
|
|
19901
|
+
entries: notificationLogRef.current.list(), statusLine: notificationStatusLine, nowMs: now.getTime(),
|
|
19902
|
+
capacity: notificationRows, offset: notificationsOffset, liveRetry,
|
|
19903
|
+
});
|
|
19904
|
+
}
|
|
19905
|
+
notificationPageRef.current = { pageRows: notificationView?.pageRows ?? 1,
|
|
19906
|
+
maxOffset: notificationView?.maxOffset ?? 0 };
|
|
19167
19907
|
let helpPages = [];
|
|
19168
|
-
if (showHelp) {
|
|
19908
|
+
if (showHelp && notificationsOpen) {
|
|
19909
|
+
helpPages = contextHelpPages({ view: {
|
|
19910
|
+
title: NOTIFICATIONS_LABEL.full,
|
|
19911
|
+
action: "Press 1-4 or Esc to return to a section; Up/Down scroll; r checks GitHub status.",
|
|
19912
|
+
sourceCheck: notificationStatusLine,
|
|
19913
|
+
explanation: "Lines above a table appear only when you must act. Waits, refreshes, failed refreshes and cleared causes are listed here, newest first.",
|
|
19914
|
+
notes: [],
|
|
19915
|
+
}, target: selectedTarget, tab: "notifications" }, keyTableLines(), cols - 5, bodyRows);
|
|
19916
|
+
} else if (showHelp) {
|
|
19169
19917
|
const fullDiagnostic = formatTabError(tabError, failureContext);
|
|
19170
19918
|
const helpView = { ...presentation, notes: [
|
|
19171
19919
|
...(fullDiagnostic ? [fullDiagnostic] : []),
|
|
@@ -19602,6 +20350,33 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19602
20350
|
}
|
|
19603
20351
|
// Width mode owns every other key. In particular, digits, arrows, Tab,
|
|
19604
20352
|
// Enter and r must never fall through to their ordinary meanings.
|
|
20353
|
+
} else if (notificationsOpenRef.current && !showHelpRef.current) {
|
|
20354
|
+
// The view owns its keys; data-tab actions (Enter, /, s, g, w, d, a)
|
|
20355
|
+
// have nothing to act on here and are ignored rather than applied to
|
|
20356
|
+
// the hidden tab.
|
|
20357
|
+
const { pageRows, maxOffset } = notificationPageRef.current;
|
|
20358
|
+
if (input === "?") {
|
|
20359
|
+
setHelpPage(0);
|
|
20360
|
+
setShowHelp(true);
|
|
20361
|
+
} else if (key.escape) {
|
|
20362
|
+
setNotificationsOpen(false);
|
|
20363
|
+
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
|
|
20364
|
+
selectPosition(Number(input) - 1);
|
|
20365
|
+
} else if (key.tab && key.shift || key.leftArrow) {
|
|
20366
|
+
stepPosition(-1);
|
|
20367
|
+
} else if (key.tab || key.rightArrow) {
|
|
20368
|
+
stepPosition(1);
|
|
20369
|
+
} else if (key.downArrow || input === "j" || key.upArrow || input === "k" || key.pageDown || key.pageUp) {
|
|
20370
|
+
const step = key.pageDown ? pageRows : key.pageUp ? -pageRows
|
|
20371
|
+
: key.downArrow || input === "j" ? 1 : -1;
|
|
20372
|
+
setNotificationsOffset((current) => Math.max(0, Math.min(maxOffset, current + step)));
|
|
20373
|
+
} else if (input === "r" || input === "R") {
|
|
20374
|
+
// In this view, r asks GitHub's status page, not the hidden data tab.
|
|
20375
|
+
void githubStatusRef.current?.checkNow();
|
|
20376
|
+
}
|
|
20377
|
+
} else if (key.escape && showHelpRef.current && notificationsOpenRef.current) {
|
|
20378
|
+
// The view's help says Esc returns; it must not quit from here.
|
|
20379
|
+
setShowHelp(false);
|
|
19605
20380
|
} else if (key.escape && showHelpRef.current && featureRef.current) {
|
|
19606
20381
|
setShowHelp(false);
|
|
19607
20382
|
} else if (key.escape && featureRef.current?.semantic?.consent) {
|
|
@@ -19743,14 +20518,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19743
20518
|
// no local coordinator; its collector resumes on the refresh it receives.
|
|
19744
20519
|
if (request) retryPausedTransport(runtimeIdentityCoordinator);
|
|
19745
20520
|
if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
|
|
19746
|
-
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
|
|
19747
|
-
|
|
19748
|
-
} else if (key.tab && key.shift) {
|
|
19749
|
-
|
|
20521
|
+
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length + 1)) {
|
|
20522
|
+
selectPosition(Number(input) - 1);
|
|
20523
|
+
} else if (key.tab && key.shift || key.leftArrow) {
|
|
20524
|
+
stepPosition(-1);
|
|
19750
20525
|
} else if (key.tab || key.rightArrow) {
|
|
19751
|
-
|
|
19752
|
-
} else if (key.leftArrow) {
|
|
19753
|
-
setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
|
|
20526
|
+
stepPosition(1);
|
|
19754
20527
|
}
|
|
19755
20528
|
},
|
|
19756
20529
|
// `isActive` has to be coerced: ink only skips raw mode when the flag is
|
|
@@ -20422,6 +21195,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20422
21195
|
const inFlightStartedAt = {};
|
|
20423
21196
|
const wakeScheduler = createWakeScheduler();
|
|
20424
21197
|
let scope = null;
|
|
21198
|
+
let notificationsSeeded = false;
|
|
20425
21199
|
let scopeFailure = null;
|
|
20426
21200
|
let cleanupScope = null;
|
|
20427
21201
|
let registeredScopeHash = null;
|
|
@@ -21049,6 +21823,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
21049
21823
|
scope = createQuotaScope(current, { root: runtimeIdentityCoordinator.root, identityProvider: identity });
|
|
21050
21824
|
cleanupScope = { ...scope, identityProvider: null };
|
|
21051
21825
|
const savedRecovery = readRecoveryTransitions(scope);
|
|
21826
|
+
// Earlier transitions become dimmed history, once per pane, from the
|
|
21827
|
+
// journal just read; collector clients have no journal and start empty.
|
|
21828
|
+
if (!notificationsSeeded) {
|
|
21829
|
+
notificationsSeeded = true;
|
|
21830
|
+
seedNotificationsFromJournal(notificationLogRef.current, scope, () => savedRecovery);
|
|
21831
|
+
}
|
|
21052
21832
|
recoveryRef.current = savedRecovery.status === "healthy"
|
|
21053
21833
|
? { ...savedRecovery.current } : { core: null, graphql: null };
|
|
21054
21834
|
setRecoveryCauses(recoveryRef.current);
|
|
@@ -22228,8 +23008,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22228
23008
|
}, [reconciledSelection.key, reconciledSelection.offset, selectedKey, tabOffsetRaw, tab.key]);
|
|
22229
23009
|
// Bottom-right of the frame, lazygit style: how much of the tab you can
|
|
22230
23010
|
// currently see out of how much there is.
|
|
22231
|
-
const
|
|
22232
|
-
|
|
23011
|
+
const notificationSummary = notificationsOpen ? notificationLogRef.current.summary() : null;
|
|
23012
|
+
const countLabel = notificationSummary
|
|
23013
|
+
? notificationSummary.truncated ? `Showing the latest ${NOTIFICATION_LOG_MAX} events`
|
|
23014
|
+
: `${notificationSummary.size} ${notificationSummary.size === 1 ? "event" : "events"}`
|
|
23015
|
+
: !tableVisible
|
|
22233
23016
|
? null
|
|
22234
23017
|
: tabOffset > 0
|
|
22235
23018
|
? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
|
|
@@ -22245,8 +23028,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22245
23028
|
|
|
22246
23029
|
// The existing resolver also supplies inferred targets without another lookup.
|
|
22247
23030
|
// Complete target and host remain reachable in help when this edge is short.
|
|
22248
|
-
const
|
|
22249
|
-
const
|
|
23031
|
+
const viewLabel = notificationsOpen ? NOTIFICATIONS_LABEL.full : tab.label;
|
|
23032
|
+
const withTarget = `${viewLabel} · ${selectedTarget.label}`;
|
|
23033
|
+
const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : viewLabel;
|
|
22250
23034
|
|
|
22251
23035
|
// Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
|
|
22252
23036
|
// at ordinary widths. Narrow panes drop columns instead, which keeps the
|
|
@@ -22331,18 +23115,24 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22331
23115
|
const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
|
|
22332
23116
|
const frameRows = Math.max(1, liveRows - 1);
|
|
22333
23117
|
|
|
23118
|
+
const tabEntries = tabBarEntries({
|
|
23119
|
+
cols: frameCols,
|
|
23120
|
+
counts,
|
|
23121
|
+
brokenCI,
|
|
23122
|
+
firstLoad,
|
|
23123
|
+
failed,
|
|
23124
|
+
spin,
|
|
23125
|
+
preferShort: shortTabLabelsRef.current,
|
|
23126
|
+
activeIndex: notificationsOpen ? TABS.length : activeIndex,
|
|
23127
|
+
// An active GitHub outage keeps the badge red even after it was read.
|
|
23128
|
+
notifications: githubStatus.outage ? { ...notificationBadge, tone: "red" } : notificationBadge,
|
|
23129
|
+
});
|
|
23130
|
+
shortTabLabelsRef.current = tabEntries[0].tier !== "full";
|
|
23131
|
+
|
|
22334
23132
|
return e(
|
|
22335
23133
|
Box,
|
|
22336
23134
|
{ flexDirection: "column", width: frameCols, height: frameRows },
|
|
22337
|
-
e(TabBar, {
|
|
22338
|
-
activeIndex,
|
|
22339
|
-
counts,
|
|
22340
|
-
brokenCI,
|
|
22341
|
-
firstLoad,
|
|
22342
|
-
failed,
|
|
22343
|
-
spin,
|
|
22344
|
-
useShort: useShortLabels,
|
|
22345
|
-
}),
|
|
23135
|
+
e(TabBar, { entries: tabEntries }),
|
|
22346
23136
|
e(Divider, { width: frameCols }),
|
|
22347
23137
|
e(PanelEdge, { width: frameCols, top: true, label: topLabel, labelColor: TITLE_COLOR }),
|
|
22348
23138
|
e(
|
|
@@ -22371,20 +23161,20 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22371
23161
|
showHelp &&
|
|
22372
23162
|
(helpPages[visibleHelpPage] ?? [])
|
|
22373
23163
|
.map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
|
|
23164
|
+
!showHelp && notificationView &&
|
|
23165
|
+
notificationView.lines.map((line, index) => e(Text, {
|
|
23166
|
+
key: `notification:${index}`, wrap: "truncate-end", dimColor: line.dim,
|
|
23167
|
+
color: line.level === "status" ? TITLE_COLOR : line.dim ? undefined
|
|
23168
|
+
: line.level === "wait" ? ATTENTION : ["action", "problem"].includes(line.level) ? ERROR_TEXT : undefined,
|
|
23169
|
+
}, line.text)),
|
|
22374
23170
|
!showHelp &&
|
|
23171
|
+
!notificationsOpen &&
|
|
22375
23172
|
tooNarrow &&
|
|
22376
23173
|
e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
|
|
22377
|
-
...(showHelp || !tableVisible ? [] : noticeLines).map((line, index) =>
|
|
23174
|
+
...(showHelp || notificationsOpen || !tableVisible ? [] : noticeLines).map((line, index) =>
|
|
22378
23175
|
e(Text, { key: `recovery:${index}`, color: noticeTone, wrap: "truncate-end" }, line)),
|
|
22379
23176
|
!showHelp &&
|
|
22380
|
-
!
|
|
22381
|
-
!remoteSetup &&
|
|
22382
|
-
tableVisible &&
|
|
22383
|
-
tab.key === "security" &&
|
|
22384
|
-
visibleSecurityLines.map((note, i) =>
|
|
22385
|
-
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
22386
|
-
),
|
|
22387
|
-
!showHelp &&
|
|
23177
|
+
!notificationsOpen &&
|
|
22388
23178
|
!tooNarrow &&
|
|
22389
23179
|
remoteSetup &&
|
|
22390
23180
|
(interactive ? REMOTE_SETUP_LINES : REMOTE_SETUP_NONINTERACTIVE_LINES).map(
|
|
@@ -22402,6 +23192,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22402
23192
|
),
|
|
22403
23193
|
),
|
|
22404
23194
|
!showHelp &&
|
|
23195
|
+
!notificationsOpen &&
|
|
22405
23196
|
!tooNarrow &&
|
|
22406
23197
|
!remoteSetup &&
|
|
22407
23198
|
tableVisible &&
|
|
@@ -22410,7 +23201,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22410
23201
|
selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
|
|
22411
23202
|
headerRef,
|
|
22412
23203
|
}),
|
|
22413
|
-
...(tooNarrow || showHelp || remoteSetup || ciView || attentionView ? [] : visibleItems).map((item) => {
|
|
23204
|
+
...(tooNarrow || showHelp || notificationsOpen || remoteSetup || ciView || attentionView ? [] : visibleItems).map((item) => {
|
|
22414
23205
|
const key = itemKey(item);
|
|
22415
23206
|
return e(
|
|
22416
23207
|
RowBoundary,
|
|
@@ -22431,7 +23222,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22431
23222
|
}),
|
|
22432
23223
|
);
|
|
22433
23224
|
}),
|
|
22434
|
-
!showHelp && !remoteSetup && !tableVisible &&
|
|
23225
|
+
!showHelp && !notificationsOpen && !remoteSetup && !tableVisible &&
|
|
22435
23226
|
panelLines.map((line, index) => e(Text, {
|
|
22436
23227
|
key: `context:${index}`, wrap: "truncate-end",
|
|
22437
23228
|
bold: index === 0, color: index === 0 ? TITLE_COLOR : undefined,
|
|
@@ -22451,17 +23242,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22451
23242
|
nowMs: now.getTime(),
|
|
22452
23243
|
interactive,
|
|
22453
23244
|
cols: frameCols,
|
|
22454
|
-
remoteSetup,
|
|
22455
|
-
feature: attentionView ?? ciView ?? focus,
|
|
23245
|
+
remoteSetup: remoteSetup && !notificationsOpen,
|
|
23246
|
+
feature: notificationsOpen ? null : attentionView ?? ciView ?? focus,
|
|
22456
23247
|
widthMode: resizeRef.current.active,
|
|
22457
23248
|
widthColumn: selectedWidthColumn,
|
|
22458
23249
|
widthSaveError,
|
|
22459
|
-
canMove: allItems.length > 0,
|
|
23250
|
+
canMove: notificationsOpen ? (notificationView?.maxOffset ?? 0) > 0 : allItems.length > 0,
|
|
22460
23251
|
canOpen:
|
|
23252
|
+
!notificationsOpen &&
|
|
22461
23253
|
selectedKey !== null &&
|
|
22462
23254
|
OPENABLE.includes(tab.key) &&
|
|
22463
23255
|
allItems.some((item) => itemKey(item) === selectedKey),
|
|
22464
|
-
canResize: fullHeaderVisible,
|
|
23256
|
+
canResize: !notificationsOpen && fullHeaderVisible,
|
|
22465
23257
|
}),
|
|
22466
23258
|
);
|
|
22467
23259
|
}
|
|
@@ -23083,6 +23875,25 @@ export {
|
|
|
23083
23875
|
collectorSourceAdvanced,
|
|
23084
23876
|
cleanupQueueForResource,
|
|
23085
23877
|
presentRecovery,
|
|
23878
|
+
RECOVERY_COPY,
|
|
23879
|
+
NOTICE_LEVELS,
|
|
23880
|
+
NOTIFICATION_LOG_MAX,
|
|
23881
|
+
noticeLevel,
|
|
23882
|
+
fetchErrorLevel,
|
|
23883
|
+
createNotificationLog,
|
|
23884
|
+
notificationConditions,
|
|
23885
|
+
diffNotificationSnapshots,
|
|
23886
|
+
seedNotificationsFromJournal,
|
|
23887
|
+
criticalInlineLines,
|
|
23888
|
+
notificationViewLines,
|
|
23889
|
+
tabBarEntries,
|
|
23890
|
+
tabBarWidth,
|
|
23891
|
+
GITHUB_STATUS_URL,
|
|
23892
|
+
GITHUB_STATUS_MAX_BYTES,
|
|
23893
|
+
githubStatusConfig,
|
|
23894
|
+
fetchGitHubStatus,
|
|
23895
|
+
githubStatusLine,
|
|
23896
|
+
createGitHubStatusMonitor,
|
|
23086
23897
|
dashboardPresentation,
|
|
23087
23898
|
contextualPanelLayout,
|
|
23088
23899
|
dashboardTarget,
|