gh-glance 0.18.0 → 0.19.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 +63 -1
- package/README.md +92 -37
- package/index.mjs +1185 -162
- 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;
|
|
@@ -761,6 +763,9 @@ async function runGh(args, { signal, operation, input = null, execute = null,
|
|
|
761
763
|
let control = null;
|
|
762
764
|
const identityCoordinator = bound?.identityCoordinator ?? runtimeIdentityCoordinator;
|
|
763
765
|
const requestNow = typeof bound?.now === "function" ? bound.now : Date.now;
|
|
766
|
+
if (identityCoordinator && bound && operation === "budget-core-observer") {
|
|
767
|
+
retryTransportAfterReset(identityCoordinator, bound, requestNow);
|
|
768
|
+
}
|
|
764
769
|
const permit = identityCoordinator && !local
|
|
765
770
|
? await acquireIdentityHttpPermit(identityCoordinator, { signal, now: requestNow,
|
|
766
771
|
operationDeadline: CONTROL_OPERATIONS.includes(operation) ? undefined : bound?.receiptCapability?.deadline,
|
|
@@ -2555,13 +2560,14 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
2555
2560
|
const incomplete = merged.incomplete || truncatedWalk;
|
|
2556
2561
|
// Serialized rows are the payload identity the unchanged-frame suppression
|
|
2557
2562
|
// and the cache both compare on, so it must cover exactly what is rendered --
|
|
2558
|
-
// including the markers that say the list is not all of it.
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
hasNextPage: merged.hasNextPage,
|
|
2563
|
+
// including the markers that say the list is not all of it. It is the shared
|
|
2564
|
+
// representation a stored snapshot rebuilds, so a collector producer that
|
|
2565
|
+
// compares against its normalized previous snapshot can see "unchanged".
|
|
2566
|
+
// `truncated` is what pollResultTransition derives from the limit below.
|
|
2567
|
+
const raw = acquisitionContentRaw(kind, {
|
|
2564
2568
|
rows: merged.rows,
|
|
2569
|
+
truncated: incomplete || merged.rows.length >= LIST_LIMIT,
|
|
2570
|
+
hasNextPage: merged.hasNextPage,
|
|
2565
2571
|
});
|
|
2566
2572
|
return {
|
|
2567
2573
|
raw: raw === previousRaw ? previousRaw : raw,
|
|
@@ -4187,6 +4193,19 @@ function classifyThrottle({ status = null, headers = null, graphqlErrors = null,
|
|
|
4187
4193
|
return { kind: "none" };
|
|
4188
4194
|
}
|
|
4189
4195
|
|
|
4196
|
+
// A "rate limit" message does not say which limit. Secondary limits are already
|
|
4197
|
+
// held by the shared transport cooldown; publishing one as a resource block until
|
|
4198
|
+
// the sampled primary reset would hold every pane for the rest of the window.
|
|
4199
|
+
// Only evidence classified as secondary is excluded. Stderr alone ("API rate
|
|
4200
|
+
// limit exceeded" with no headers) classifies as none and stays primary.
|
|
4201
|
+
function secondaryThrottleEvidence(error) {
|
|
4202
|
+
if (error?.coordinationReason === "throttle-paused") return true;
|
|
4203
|
+
const response = nativeCiThrottleEvidence.get(error) ?? error?.apiResponse ??
|
|
4204
|
+
parseGhApiResponse(error?.stdout ?? "");
|
|
4205
|
+
return classifyThrottle({ status: response.status, headers: response.headers, stderr: errText(error) })
|
|
4206
|
+
.kind === "secondary";
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4190
4209
|
function transportCooldownDeadline({ retryAfter, status, secondary, at, attempts = 0 }) {
|
|
4191
4210
|
const supplied = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
|
|
4192
4211
|
? at + Number(retryAfter) * 1000
|
|
@@ -4271,17 +4290,40 @@ function releaseIdentityHttpPermit(coordinator, permit, error = null, now = Date
|
|
|
4271
4290
|
return result;
|
|
4272
4291
|
}
|
|
4273
4292
|
|
|
4274
|
-
// The explicit retry a paused transport waits
|
|
4275
|
-
//
|
|
4276
|
-
//
|
|
4277
|
-
|
|
4293
|
+
// The explicit retry, or the reset-triggered recovery, a paused transport waits
|
|
4294
|
+
// for. Clears the ladder and the pause but never the deadline: a server-supplied
|
|
4295
|
+
// Retry-After is honoured exactly (ADR 0003), so the next request still waits it
|
|
4296
|
+
// out. The registry lock is the election: only the first caller resumes a given
|
|
4297
|
+
// pause. A reset recovers only a pause whose last throttle predates that reset,
|
|
4298
|
+
// so one primary window elects at most one recovery.
|
|
4299
|
+
function retryThrottledTransport(root, host, { now = Date.now, resetAt = null } = {}) {
|
|
4300
|
+
const at = now();
|
|
4278
4301
|
return withIdentityRegistry(root, (state) => {
|
|
4279
4302
|
const transport = state.hosts[host];
|
|
4280
4303
|
if (!transport?.throttle?.paused) return { ok: true, value: { resumed: false } };
|
|
4304
|
+
if (resetAt !== null &&
|
|
4305
|
+
!(Number.isFinite(resetAt) && at >= resetAt && transport.throttle.lastAt < resetAt)) {
|
|
4306
|
+
return { ok: true, value: { resumed: false } };
|
|
4307
|
+
}
|
|
4281
4308
|
transport.throttle = emptyTransportThrottle();
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4309
|
+
return { ok: true, value: { resumed: true, cooldownUntil: transport.cooldownUntil } };
|
|
4310
|
+
}, { now: at });
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
function retryPausedTransport(coordinator, options = {}) {
|
|
4314
|
+
const host = coordinator?.current?.()?.host;
|
|
4315
|
+
if (typeof coordinator?.root !== "string" || !host) return { ok: true, value: { resumed: false } };
|
|
4316
|
+
return retryThrottledTransport(coordinator.root, host, options);
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
// Only the pane holding the core observer claim runs budget-core-observer, so the
|
|
4320
|
+
// claim elects the one request that finds out whether the limit has lifted.
|
|
4321
|
+
function retryTransportAfterReset(coordinator, scope, now = Date.now) {
|
|
4322
|
+
const at = now();
|
|
4323
|
+
const snapshot = inspectGovernor({ ...scope, identityProvider: null }, at);
|
|
4324
|
+
const resetAt = snapshot.ok ? snapshot.value.budgets.core?.resetMs : null;
|
|
4325
|
+
if (!Number.isFinite(resetAt) || at < resetAt) return { ok: true, value: { resumed: false } };
|
|
4326
|
+
return retryPausedTransport(coordinator, { now: () => at, resetAt });
|
|
4285
4327
|
}
|
|
4286
4328
|
|
|
4287
4329
|
function identityCoordinationMessage(reason) {
|
|
@@ -5363,7 +5405,7 @@ function readGovernorState(path, nowMs, { persistMigration = true } = {}) {
|
|
|
5363
5405
|
if (Buffer.byteLength(raw) > GOVERNOR_MAX_LEDGER_BYTES) return { ok: false, reason: "capacity" };
|
|
5364
5406
|
const parsed = JSON.parse(raw);
|
|
5365
5407
|
const normalized = normalizeGovernorState(parsed, nowMs);
|
|
5366
|
-
if (normalized) return { ok: true, value: normalized };
|
|
5408
|
+
if (normalized) return { ok: true, value: normalized, bytes: raw };
|
|
5367
5409
|
if (Number.isSafeInteger(parsed?.version) && parsed.version > GOVERNOR_STATE_VERSION) {
|
|
5368
5410
|
return { ok: false, reason: "version-mismatch" };
|
|
5369
5411
|
}
|
|
@@ -5785,6 +5827,13 @@ function mutateGovernor(scope, nowMs, mutate) {
|
|
|
5785
5827
|
if (!governorAccountingSafe(state, at)) return returnAfterMigration(ACCOUNTING_OVERFLOW);
|
|
5786
5828
|
const normalized = normalizeGovernorState(state, at);
|
|
5787
5829
|
if (!normalized) return returnAfterMigration({ ok: false, reason: "corrupt" });
|
|
5830
|
+
// A wait or decision read that changed nothing must not bump the revision
|
|
5831
|
+
// and replace the file: compare the would-be bytes, as the identity
|
|
5832
|
+
// registry does. Migrations and explicit rejected writes always commit.
|
|
5833
|
+
if (migration === null && value?.ok !== false && typeof loaded.bytes === "string" &&
|
|
5834
|
+
serializeGovernorState(normalized) === loaded.bytes) {
|
|
5835
|
+
return { ok: true, value: value?.value };
|
|
5836
|
+
}
|
|
5788
5837
|
const written = commitGovernorMigration(scope.path, normalized, loaded.migration);
|
|
5789
5838
|
if (!written.ok) return written;
|
|
5790
5839
|
return value?.ok === false ? { ok: false, reason: value.reason } : { ok: true, value: value?.value };
|
|
@@ -6512,9 +6561,11 @@ function startReservation(scope, reservationId, nowMs) {
|
|
|
6512
6561
|
return { ok: false, reason: "stale", write: Boolean(reservation) };
|
|
6513
6562
|
}
|
|
6514
6563
|
if (reservation.notBefore > at) return { value: { status: "waiting", notBefore: reservation.notBefore } };
|
|
6515
|
-
//
|
|
6516
|
-
//
|
|
6564
|
+
// A live observer claim defers only work that charges its resource. The
|
|
6565
|
+
// other resource's observer says nothing about this counter, and the shared
|
|
6566
|
+
// HTTP pool, start gap and host cooldown still bound the two together.
|
|
6517
6567
|
const liveClaim = RATE_RESOURCES
|
|
6568
|
+
.filter((resource) => reservation.costs[resource] > 0)
|
|
6518
6569
|
.map((resource) => state.probeClaims[resource])
|
|
6519
6570
|
.filter((claim) => claim && claim.leaseUntil > at)
|
|
6520
6571
|
.sort((left, right) => right.leaseUntil - left.leaseUntil)[0];
|
|
@@ -7240,9 +7291,15 @@ function mergeAlertRows(groups) {
|
|
|
7240
7291
|
// mid-session is picked up within the hour.
|
|
7241
7292
|
const alertBackoff = new Map();
|
|
7242
7293
|
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7294
|
+
// A capability verdict is a statement about one repository on one host, so the
|
|
7295
|
+
// bound host and repository are part of the key: one collector credential can
|
|
7296
|
+
// serve several repositories, and one repository's 403 must not replay for
|
|
7297
|
+
// (or be cleared by) another.
|
|
7298
|
+
function backoffStorageKey(key, accessKey = null) {
|
|
7299
|
+
const access = accessKey ?? requestIdentityStorage.getStore()?.accessKey ??
|
|
7300
|
+
runtimeIdentityCoordinator?.current()?.accessKey;
|
|
7301
|
+
const scoped = `${effectiveRuntimeHost() ?? ""}\0${effectiveRuntimeRepository()?.toLowerCase() ?? ""}\0${key}`;
|
|
7302
|
+
return access ? `${access}\0${scoped}` : scoped;
|
|
7246
7303
|
}
|
|
7247
7304
|
|
|
7248
7305
|
function backoffActive(key, now) {
|
|
@@ -7428,6 +7485,7 @@ async function fetchAlertSource(source, signal, now, {
|
|
|
7428
7485
|
rest200,
|
|
7429
7486
|
rest304,
|
|
7430
7487
|
verdict,
|
|
7488
|
+
secondaryThrottle: verdict === "rate-limited" && secondaryThrottleEvidence(err),
|
|
7431
7489
|
allNotModified: false,
|
|
7432
7490
|
stagedEntities: new Map(),
|
|
7433
7491
|
observations,
|
|
@@ -7499,7 +7557,15 @@ async function fetchSecurity(signal, {
|
|
|
7499
7557
|
requestMetrics,
|
|
7500
7558
|
graphqlSpent: 0,
|
|
7501
7559
|
measuredSuccess: parts.every((part) => part.verdict === "ok"),
|
|
7502
|
-
|
|
7560
|
+
// Whether this batch observed the source at all: some source made a real
|
|
7561
|
+
// request and GitHub answered it (200/304, or a current capability refusal).
|
|
7562
|
+
// A batch in which every source replayed its backoff made no request, so it
|
|
7563
|
+
// must not advance freshness or complete webhook work (plan D6, ADR 0005).
|
|
7564
|
+
// With no sources (an App granted no Security permission) there is nothing
|
|
7565
|
+
// to observe, and the configuration answer keeps its existing behavior.
|
|
7566
|
+
sourceObserved: parts.length === 0 || parts.some((part) => part.httpRequests > 0 &&
|
|
7567
|
+
(part.verdict === "ok" || part.verdict === "unavailable")),
|
|
7568
|
+
rateLimited: parts.some((part) => part.verdict === "rate-limited" && !part.secondaryThrottle),
|
|
7503
7569
|
stagedEntities,
|
|
7504
7570
|
observations: parts.flatMap((part) => part.observations ?? []),
|
|
7505
7571
|
capabilities,
|
|
@@ -7774,7 +7840,16 @@ function createCollectorSourceAgeTracker({ now = Date.now, monotonicNow = () =>
|
|
|
7774
7840
|
lowerBound += Math.max(0, now() - checkpoint.clientCheckpointAt);
|
|
7775
7841
|
}
|
|
7776
7842
|
let measuredAt = monotonicNow();
|
|
7777
|
-
|
|
7843
|
+
// The monotonic clock does not advance across a suspend, so positive wall
|
|
7844
|
+
// elapsed time also bounds the age. A forward wall step can only overstate
|
|
7845
|
+
// staleness; the high-water mark keeps a backward step from shrinking it.
|
|
7846
|
+
let wallMeasuredAt = now();
|
|
7847
|
+
let reported = lowerBound;
|
|
7848
|
+
const current = () => {
|
|
7849
|
+
reported = Math.max(reported, lowerBound +
|
|
7850
|
+
Math.max(0, monotonicNow() - measuredAt, now() - wallMeasuredAt));
|
|
7851
|
+
return reported;
|
|
7852
|
+
};
|
|
7778
7853
|
return {
|
|
7779
7854
|
observe({ lastSuccessAt, serverNow }) {
|
|
7780
7855
|
if (!Number.isFinite(lastSuccessAt) || !Number.isFinite(serverNow)) return current();
|
|
@@ -7784,6 +7859,8 @@ function createCollectorSourceAgeTracker({ now = Date.now, monotonicNow = () =>
|
|
|
7784
7859
|
else lowerBound = Math.max(elapsed, candidate);
|
|
7785
7860
|
sourceAt = sourceAt === null ? lastSuccessAt : Math.max(sourceAt, lastSuccessAt);
|
|
7786
7861
|
measuredAt = monotonicNow();
|
|
7862
|
+
wallMeasuredAt = now();
|
|
7863
|
+
reported = lowerBound;
|
|
7787
7864
|
return lowerBound;
|
|
7788
7865
|
},
|
|
7789
7866
|
current,
|
|
@@ -10045,7 +10122,11 @@ async function createCollectorService({ config, pathOptions = {}, runtime, platf
|
|
|
10045
10122
|
}
|
|
10046
10123
|
|
|
10047
10124
|
function collectorPublicationFromResult(resource, result, previous, demand, completedAt, state = {}) {
|
|
10048
|
-
|
|
10125
|
+
// A Security batch that only replayed capability backoff made no request. It
|
|
10126
|
+
// republishes the previous observation unchanged, with its success time, so
|
|
10127
|
+
// neither freshness nor webhook completion can advance on it.
|
|
10128
|
+
const observed = result.sourceObserved !== false;
|
|
10129
|
+
const transition = !observed ? { kind: "unchanged", completedAt } : pollResultTransition({
|
|
10049
10130
|
key: resource,
|
|
10050
10131
|
previousRaw: previous?.raw ?? null,
|
|
10051
10132
|
raw: result.raw,
|
|
@@ -10074,7 +10155,8 @@ function collectorPublicationFromResult(resource, result, previous, demand, comp
|
|
|
10074
10155
|
},
|
|
10075
10156
|
raw: transition.kind === "changed" ? transition.nextRaw : previous.raw,
|
|
10076
10157
|
entities,
|
|
10077
|
-
lastSuccessAt: completedAt,
|
|
10158
|
+
lastSuccessAt: observed ? completedAt : previous.lastSuccessAt,
|
|
10159
|
+
sourceObserved: observed,
|
|
10078
10160
|
lastChangedAt,
|
|
10079
10161
|
nextDueAt: completedAt + pollPolicyInterval({
|
|
10080
10162
|
tab: resource,
|
|
@@ -10181,11 +10263,13 @@ function createCollectorAcquisitionRuntime({
|
|
|
10181
10263
|
providerContexts.set(target.provider, context);
|
|
10182
10264
|
}
|
|
10183
10265
|
let identity;
|
|
10266
|
+
let credentialEnvironment = null;
|
|
10267
|
+
let capabilities = null;
|
|
10184
10268
|
if (context.definition.type === "github-app") {
|
|
10185
10269
|
const resolved = await context.coordinator.resolve({ signal });
|
|
10186
10270
|
identity = resolved.identity;
|
|
10187
|
-
|
|
10188
|
-
|
|
10271
|
+
credentialEnvironment = resolved.environment;
|
|
10272
|
+
capabilities = resolved.capabilities;
|
|
10189
10273
|
if (context.identity?.accessKey !== identity.accessKey) {
|
|
10190
10274
|
const persisted = persistGitHubAppIdentity(context.coordinator.root, identity, now());
|
|
10191
10275
|
if (!persisted.ok) throw new Error("collector provider unavailable");
|
|
@@ -10194,13 +10278,10 @@ function createCollectorAcquisitionRuntime({
|
|
|
10194
10278
|
const resolved = await context.coordinator.refresh();
|
|
10195
10279
|
if (!resolved.ok) throw new Error("collector provider unavailable");
|
|
10196
10280
|
identity = resolved.value;
|
|
10197
|
-
context.credentialEnvironment = null;
|
|
10198
|
-
context.capabilities = null;
|
|
10199
10281
|
context.readBudgets = readBudgets;
|
|
10200
10282
|
}
|
|
10201
10283
|
if (identity.host !== target.host) throw new Error("collector provider unavailable");
|
|
10202
|
-
|
|
10203
|
-
context.scope = {
|
|
10284
|
+
const scope = Object.freeze({
|
|
10204
10285
|
...createQuotaScope(identity, {
|
|
10205
10286
|
root: context.coordinator.root,
|
|
10206
10287
|
now,
|
|
@@ -10210,9 +10291,28 @@ function createCollectorAcquisitionRuntime({
|
|
|
10210
10291
|
repository: target.repo,
|
|
10211
10292
|
providerType: context.definition.type,
|
|
10212
10293
|
...(executeGh ? { executeGh } : {}),
|
|
10213
|
-
...(
|
|
10214
|
-
};
|
|
10215
|
-
|
|
10294
|
+
...(credentialEnvironment ? { credentialEnvironment } : {}),
|
|
10295
|
+
});
|
|
10296
|
+
// The provider context keeps only provider-wide state (coordinator,
|
|
10297
|
+
// lease, budget reader, latest access identity). Its scope is used to
|
|
10298
|
+
// release the provider lease on close, never to bind a request.
|
|
10299
|
+
context.identity = identity;
|
|
10300
|
+
context.credentialEnvironment = credentialEnvironment;
|
|
10301
|
+
context.capabilities = capabilities;
|
|
10302
|
+
context.scope = { ...scope };
|
|
10303
|
+
// Each target operation gets its own immutable binding, captured once, so
|
|
10304
|
+
// another target's resolution cannot redirect it after an await.
|
|
10305
|
+
return Object.freeze({
|
|
10306
|
+
coordinator: context.coordinator,
|
|
10307
|
+
definition: context.definition,
|
|
10308
|
+
leaseId: context.leaseId,
|
|
10309
|
+
readBudgets: context.readBudgets,
|
|
10310
|
+
onObserverPublished: context.onObserverPublished,
|
|
10311
|
+
identity,
|
|
10312
|
+
scope,
|
|
10313
|
+
credentialEnvironment,
|
|
10314
|
+
capabilities,
|
|
10315
|
+
});
|
|
10216
10316
|
}
|
|
10217
10317
|
|
|
10218
10318
|
async function defaultProduce({ target, resource, demand, snapshot, claim, markStarted, provider, signal, force }) {
|
|
@@ -10444,9 +10544,17 @@ function createCollectorAcquisitionRuntime({
|
|
|
10444
10544
|
item.invalidations.clear();
|
|
10445
10545
|
}
|
|
10446
10546
|
|
|
10547
|
+
// Identifies one delivered generation for one engine subscription and
|
|
10548
|
+
// binding. A rebind or a new subscription (a new connection) changes it.
|
|
10549
|
+
function collectorDeliveryKey(subscriptionId, bindingRevision, snapshot) {
|
|
10550
|
+
return Number.isSafeInteger(snapshot?.generation)
|
|
10551
|
+
? `${subscriptionId}\0${bindingRevision}\0${snapshot.generation}` : null;
|
|
10552
|
+
}
|
|
10553
|
+
|
|
10447
10554
|
function deliverCollectorSnapshot(item, snapshot, bindingRevision, { publication = false } = {}) {
|
|
10448
10555
|
if (bindingRevision !== item.bindingRevision) return false;
|
|
10449
10556
|
item.nextDueAt = snapshot.nextDueAt;
|
|
10557
|
+
item.lastDeliveryKey = collectorDeliveryKey(item.id, bindingRevision, snapshot);
|
|
10450
10558
|
if (Number.isSafeInteger(snapshot.generation)) item.lastDeliveredGeneration = snapshot.generation;
|
|
10451
10559
|
if (Number.isFinite(snapshot.lastSuccessAt)) item.lastDeliveredSuccessAt = snapshot.lastSuccessAt;
|
|
10452
10560
|
if (publication && item.requestedAt !== null && snapshot.lastSuccessAt >= item.requestedAt) {
|
|
@@ -10454,13 +10562,21 @@ function createCollectorAcquisitionRuntime({
|
|
|
10454
10562
|
}
|
|
10455
10563
|
item.onSnapshot(snapshot);
|
|
10456
10564
|
if (!publication || !Number.isSafeInteger(snapshot.generation)) return true;
|
|
10565
|
+
let awaitingObservation = false;
|
|
10457
10566
|
for (const waiter of item.invalidations) {
|
|
10458
10567
|
if (bindingRevision !== waiter.bindingRevision || waiter.requiredGeneration === null ||
|
|
10459
10568
|
snapshot.generation < waiter.requiredGeneration) continue;
|
|
10569
|
+
// A newer generation that republished an older observation (a Security
|
|
10570
|
+
// capability-backoff replay) is not API validation of the webhook. Leave
|
|
10571
|
+
// the waiter for the next real observation on the ordinary cadence.
|
|
10572
|
+
if (Number.isFinite(waiter.invalidatedAt) && !(snapshot.lastSuccessAt >= waiter.invalidatedAt)) {
|
|
10573
|
+
awaitingObservation = true;
|
|
10574
|
+
continue;
|
|
10575
|
+
}
|
|
10460
10576
|
item.invalidations.delete(waiter);
|
|
10461
10577
|
waiter.resolve(snapshot);
|
|
10462
10578
|
}
|
|
10463
|
-
if (item.invalidations.size > 0 && !closed && !item.closed && demandEnabled(item.demand)) {
|
|
10579
|
+
if (item.invalidations.size > 0 && !awaitingObservation && !closed && !item.closed && demandEnabled(item.demand)) {
|
|
10464
10580
|
queueMicrotask(() => startPoll(item, { manual: true }));
|
|
10465
10581
|
}
|
|
10466
10582
|
return true;
|
|
@@ -10531,11 +10647,15 @@ function createCollectorAcquisitionRuntime({
|
|
|
10531
10647
|
retryIn = 1;
|
|
10532
10648
|
return;
|
|
10533
10649
|
}
|
|
10534
|
-
|
|
10650
|
+
const provider = refreshedProvider;
|
|
10651
|
+
item.provider = provider;
|
|
10535
10652
|
const ownership = await engine.refresh(subscriptionId, { force: manual });
|
|
10536
10653
|
if (!ownership.ok) { item.onHold?.("disconnected"); retryIn = 1_000; return; }
|
|
10537
10654
|
if (ownership.value.role !== "producer") {
|
|
10538
|
-
|
|
10655
|
+
// A follower wake re-reads the shared record; re-send only a
|
|
10656
|
+
// generation this subscription has not already delivered.
|
|
10657
|
+
if (ownership.value.snapshot && item.lastDeliveryKey !==
|
|
10658
|
+
collectorDeliveryKey(subscriptionId, bindingRevision, ownership.value.snapshot)) {
|
|
10539
10659
|
deliverCollectorSnapshot(item, ownership.value.snapshot, bindingRevision);
|
|
10540
10660
|
}
|
|
10541
10661
|
if (ownership.value.reason !== "fresh") {
|
|
@@ -10566,7 +10686,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10566
10686
|
active: ownership.value.demand?.active || item.requestedAt !== null },
|
|
10567
10687
|
snapshot: ownership.value.snapshot,
|
|
10568
10688
|
claim: { ...claim, state: item },
|
|
10569
|
-
provider
|
|
10689
|
+
provider,
|
|
10570
10690
|
signal: item.controller.signal,
|
|
10571
10691
|
force,
|
|
10572
10692
|
markStarted: async (receipt) => {
|
|
@@ -10576,11 +10696,11 @@ function createCollectorAcquisitionRuntime({
|
|
|
10576
10696
|
return started;
|
|
10577
10697
|
},
|
|
10578
10698
|
}));
|
|
10579
|
-
const produced =
|
|
10580
|
-
? await requestIdentityStorage.run(
|
|
10699
|
+
const produced = provider?.scope
|
|
10700
|
+
? await requestIdentityStorage.run(provider.scope, produceBound)
|
|
10581
10701
|
: await produceBound();
|
|
10582
|
-
if (
|
|
10583
|
-
|
|
10702
|
+
if (provider?.definition?.type === "github-app" &&
|
|
10703
|
+
provider.coordinator.current()?.accessKey !== claim.accessKey) {
|
|
10584
10704
|
engine.refresh(subscriptionId, { claim, failure: {
|
|
10585
10705
|
hold: "disconnected",
|
|
10586
10706
|
requestMetrics: produced.requestMetrics ?? {},
|
|
@@ -10610,13 +10730,16 @@ function createCollectorAcquisitionRuntime({
|
|
|
10610
10730
|
const published = await engine.refresh(subscriptionId, { claim, publish: produced });
|
|
10611
10731
|
if (!published.ok) { item.onHold?.("disconnected"); retryIn = 1_000; }
|
|
10612
10732
|
else {
|
|
10613
|
-
|
|
10733
|
+
provider?.coordinator?.markAuthorized?.();
|
|
10734
|
+
// A replay answers the request as far as backoff allows without
|
|
10735
|
+
// advancing the success time that would otherwise clear it.
|
|
10736
|
+
if (produced.sourceObserved === false) item.requestedAt = null;
|
|
10614
10737
|
retryIn = Math.max(1, published.value.snapshot.nextDueAt - now());
|
|
10615
10738
|
}
|
|
10616
10739
|
} catch (error) {
|
|
10617
|
-
if (
|
|
10740
|
+
if (provider?.definition?.type === "github-app" && error?.apiResponse?.status === 401 &&
|
|
10618
10741
|
!error.appCredentialInvalidated) {
|
|
10619
|
-
|
|
10742
|
+
provider.coordinator.invalidate("unauthorized");
|
|
10620
10743
|
}
|
|
10621
10744
|
const hold = error?.notStarted ? "shared-wait" : acquisitionFailureHold(error);
|
|
10622
10745
|
if (claim && error?.notStarted) engine.refresh(subscriptionId, { cancel: claim });
|
|
@@ -10625,7 +10748,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10625
10748
|
requestMetrics: error?.requestMetrics ?? {},
|
|
10626
10749
|
} });
|
|
10627
10750
|
else engine.setHold(subscriptionId, hold, { resource: item.resource, accessKey: item.identity.accessKey });
|
|
10628
|
-
item.onHold?.(
|
|
10751
|
+
item.onHold?.(provider?.definition?.type === "github-app" &&
|
|
10629
10752
|
(error?.apiResponse?.status === 401 || error?.message === "GitHub App token unavailable" ||
|
|
10630
10753
|
classify(error) === "auth-problem")
|
|
10631
10754
|
? "app-auth" : hold);
|
|
@@ -10713,7 +10836,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10713
10836
|
const item = { target, resource, demand: normalizedDemand, onSnapshot, onHold, id: null, identity: null,
|
|
10714
10837
|
provider: null, polling: false, pollPhase: null, pollPromise: null, controller: null,
|
|
10715
10838
|
initializePromise: null, closed: false, timer: null, queuedRefresh: null, requestedAt: null,
|
|
10716
|
-
bindingRevision: 0, invalidations: new Set(), lastDeliveredGeneration: null,
|
|
10839
|
+
bindingRevision: 0, invalidations: new Set(), lastDeliveredGeneration: null, lastDeliveryKey: null,
|
|
10717
10840
|
lastDeliveredSuccessAt: null, promotionGeneration: null, promotionAt: null };
|
|
10718
10841
|
items.add(item);
|
|
10719
10842
|
void startInitialize(item);
|
|
@@ -10748,7 +10871,10 @@ function createCollectorAcquisitionRuntime({
|
|
|
10748
10871
|
}
|
|
10749
10872
|
return updated;
|
|
10750
10873
|
},
|
|
10751
|
-
refresh(force = false) {
|
|
10874
|
+
refresh(force = false) {
|
|
10875
|
+
retryPausedTransport(item.provider?.scope?.identityCoordinator);
|
|
10876
|
+
return startPoll(item, { manual: true, force: force === true });
|
|
10877
|
+
},
|
|
10752
10878
|
inspect() {
|
|
10753
10879
|
if (!item.id) return { source: "local collector", status: "initializing", resource: item.resource };
|
|
10754
10880
|
const inspected = engine.inspect(item.id);
|
|
@@ -10825,6 +10951,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10825
10951
|
}
|
|
10826
10952
|
item.invalidations.add({
|
|
10827
10953
|
requiredGeneration,
|
|
10954
|
+
invalidatedAt: now(),
|
|
10828
10955
|
bindingRevision: item.bindingRevision,
|
|
10829
10956
|
accessRemoved: normalized.accessRemoved,
|
|
10830
10957
|
accessOnly: normalized.accessRemoved && !demandEnabled(item.demand),
|
|
@@ -10863,7 +10990,9 @@ function createCollectorAcquisitionRuntime({
|
|
|
10863
10990
|
for (const entry of targetInitializations.values()) entry.controller.abort();
|
|
10864
10991
|
await Promise.allSettled([...items].flatMap((item) =>
|
|
10865
10992
|
[item.initializePromise, item.pollPromise].filter(Boolean)));
|
|
10866
|
-
|
|
10993
|
+
// Per-target operation contexts are frozen; drop them so a retained
|
|
10994
|
+
// handle does not keep the credential environment reachable.
|
|
10995
|
+
for (const item of items) { if (item.id) engine.unsubscribe(item.id); item.provider = null; }
|
|
10867
10996
|
items.clear();
|
|
10868
10997
|
engine.close();
|
|
10869
10998
|
for (const context of providerContexts.values()) {
|
|
@@ -11001,7 +11130,13 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
|
|
|
11001
11130
|
resourceForId: (id) => subscriptions.get(id)?.resource ?? null,
|
|
11002
11131
|
onSnapshot(snapshot, frame) {
|
|
11003
11132
|
const item = subscriptions.get(frame.id);
|
|
11004
|
-
if (!item || frame.serverEpoch !== serverEpoch || snapshot.generation
|
|
11133
|
+
if (!item || frame.serverEpoch !== serverEpoch || snapshot.generation < item.generation) return;
|
|
11134
|
+
// A same-epoch reconnect keeps the generation fence, and the server
|
|
11135
|
+
// replays its cached generation for the new subscription. The first
|
|
11136
|
+
// equal-generation snapshot on a connection acknowledges that
|
|
11137
|
+
// connection; later repeats on it are not new deliveries.
|
|
11138
|
+
if (snapshot.generation === item.generation && item.connectionAcknowledged) return;
|
|
11139
|
+
item.connectionAcknowledged = true;
|
|
11005
11140
|
item.generation = snapshot.generation;
|
|
11006
11141
|
item.controlGeneration = Math.max(item.controlGeneration, snapshot.generation);
|
|
11007
11142
|
const sourceAgeMs = item.sourceAge.observe({
|
|
@@ -11036,6 +11171,7 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
|
|
|
11036
11171
|
item.generation = -1;
|
|
11037
11172
|
item.controlGeneration = -1;
|
|
11038
11173
|
}
|
|
11174
|
+
item.connectionAcknowledged = false;
|
|
11039
11175
|
write({ type: "subscribe", id: item.id, host: item.host, repo: item.repo,
|
|
11040
11176
|
resource: item.resource, demand: item.demand });
|
|
11041
11177
|
flushRefresh(item);
|
|
@@ -11129,7 +11265,7 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
|
|
|
11129
11265
|
return { ok: false, reason: "invalid" };
|
|
11130
11266
|
}
|
|
11131
11267
|
const item = { ...subscription,
|
|
11132
|
-
onSnapshot, onHold, onError, onDiagnostic, generation: -1, controlGeneration: -1, pendingRefresh: null,
|
|
11268
|
+
onSnapshot, onHold, onError, onDiagnostic, generation: -1, controlGeneration: -1, connectionAcknowledged: false, pendingRefresh: null,
|
|
11133
11269
|
sourceAge: createCollectorSourceAgeTracker({ now, monotonicNow, checkpoint: sourceCheckpoint }) };
|
|
11134
11270
|
subscriptions.set(id, item);
|
|
11135
11271
|
if (welcomed) write({ type: "subscribe", id: item.id, host: item.host, repo: item.repo,
|
|
@@ -12226,6 +12362,7 @@ const { version } = JSON.parse(readFileSync(new URL("./package.json", import.met
|
|
|
12226
12362
|
// this file already applies to argv vectors and to classify().
|
|
12227
12363
|
const KEY_TABLE = [
|
|
12228
12364
|
["1 2 3 4", "Actions / Issues / Pull requests / Security"],
|
|
12365
|
+
["5", "Notifications: status history; Up / Down scroll, r checks GitHub status, Esc returns"],
|
|
12229
12366
|
["Left / Right", "Previous / next tab"],
|
|
12230
12367
|
["Tab / Shift+Tab", "Next / previous tab"],
|
|
12231
12368
|
["Up / Down, j / k", "Move the cursor between rows"],
|
|
@@ -12255,6 +12392,7 @@ const HELP_PRIORITY = [
|
|
|
12255
12392
|
"Enter",
|
|
12256
12393
|
"Up / Down, j / k",
|
|
12257
12394
|
"1 2 3 4",
|
|
12395
|
+
"5",
|
|
12258
12396
|
"w",
|
|
12259
12397
|
"?",
|
|
12260
12398
|
"Left / Right",
|
|
@@ -12390,6 +12528,9 @@ Environment:
|
|
|
12390
12528
|
Minimum active-tab poll interval, ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS};
|
|
12391
12529
|
safe shared grants may run later
|
|
12392
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)
|
|
12393
12534
|
GH_GLANCE_ICONS=unicode Unicode status glyphs and text row substitutes
|
|
12394
12535
|
GH_GLANCE_ICONS=ascii ASCII-only status and row icons
|
|
12395
12536
|
GH_GLANCE_NO_ANIMATION=1 Stop motion; semantic status words remain
|
|
@@ -13833,8 +13974,10 @@ function authCacheIdentity({
|
|
|
13833
13974
|
}
|
|
13834
13975
|
|
|
13835
13976
|
// Explicit targets are stable across working directories. In inferred mode the
|
|
13836
|
-
// directory
|
|
13837
|
-
//
|
|
13977
|
+
// directory and the remote slug, both resolved offline without the GitHub call
|
|
13978
|
+
// that cannot succeed when this cache is needed, form the identity: a changed
|
|
13979
|
+
// remote or a reused checkout path must not restore another repository's rows.
|
|
13980
|
+
// A null slug (no remote, or remotes that disagree) is its own key.
|
|
13838
13981
|
// JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
|
|
13839
13982
|
function dashboardCacheTarget({
|
|
13840
13983
|
repo = null,
|
|
@@ -13842,11 +13985,13 @@ function dashboardCacheTarget({
|
|
|
13842
13985
|
host = null,
|
|
13843
13986
|
cwd = process.cwd(),
|
|
13844
13987
|
account = authCacheIdentity(),
|
|
13988
|
+
remoteUrls = [],
|
|
13845
13989
|
} = {}) {
|
|
13846
13990
|
const target = repo || ghRepo;
|
|
13847
13991
|
return target
|
|
13848
13992
|
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target), account })
|
|
13849
|
-
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd),
|
|
13993
|
+
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd),
|
|
13994
|
+
remote: resolveEffectiveRepository({ remoteUrls }), account });
|
|
13850
13995
|
}
|
|
13851
13996
|
|
|
13852
13997
|
function sshCollectorCacheIdentity(alias) {
|
|
@@ -13932,6 +14077,33 @@ function normalizeCachedItem(tabKey, item) {
|
|
|
13932
14077
|
return null;
|
|
13933
14078
|
}
|
|
13934
14079
|
|
|
14080
|
+
// The one "unchanged" identity for an acquired result. Fetchers emit it, the
|
|
14081
|
+
// shared store rebuilds it from a validated snapshot, and adoption carries it,
|
|
14082
|
+
// so a producer comparing against a stored snapshot sees the same string it
|
|
14083
|
+
// would have produced itself. It covers what the tab renders: the rows through
|
|
14084
|
+
// the stored-snapshot validator, the markers that say the list is incomplete
|
|
14085
|
+
// and, for Security, the coverage notes. Timestamps are excluded on purpose.
|
|
14086
|
+
function acquisitionContentRaw(resource, { rows, truncated = false, hasNextPage = false, notes = [], blind = false }) {
|
|
14087
|
+
return JSON.stringify({
|
|
14088
|
+
v: GRAPHQL_QUERY_VERSION,
|
|
14089
|
+
resource,
|
|
14090
|
+
truncated: truncated === true,
|
|
14091
|
+
hasNextPage: hasNextPage === true,
|
|
14092
|
+
notes: resource === "security" ? notes : [],
|
|
14093
|
+
blind: resource === "security" && blind === true,
|
|
14094
|
+
rows: rows.map((row) => normalizeCachedItem(resource, row)),
|
|
14095
|
+
});
|
|
14096
|
+
}
|
|
14097
|
+
|
|
14098
|
+
// A pane's unchanged sentinel must describe the rows it displays. Adopting a
|
|
14099
|
+
// peer's different content without moving it let an A, B, A sequence compare
|
|
14100
|
+
// the third result against A and republish B as "unchanged". A snapshot that
|
|
14101
|
+
// does not carry the shared representation clears it, which costs one parse.
|
|
14102
|
+
function adoptedAcquisitionRaw(currentRaw, snapshot, sameContent) {
|
|
14103
|
+
if (sameContent) return currentRaw;
|
|
14104
|
+
return typeof snapshot?.raw === "string" ? snapshot.raw : null;
|
|
14105
|
+
}
|
|
14106
|
+
|
|
13935
14107
|
function normalizeDashboardCacheEntry(entry) {
|
|
13936
14108
|
if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
|
|
13937
14109
|
const tabs = {};
|
|
@@ -14558,6 +14730,7 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
|
|
|
14558
14730
|
const content = JSON.stringify(rows);
|
|
14559
14731
|
const contentDigest = createHash("sha256").update(content).digest("hex");
|
|
14560
14732
|
if (raw.raw === undefined && raw.contentDigest !== contentDigest) return null;
|
|
14733
|
+
const securityNotes = raw.securityNotes.filter((note) => typeof note === "string").map(safe);
|
|
14561
14734
|
return {
|
|
14562
14735
|
schema: 1,
|
|
14563
14736
|
producerEpoch,
|
|
@@ -14565,7 +14738,8 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
|
|
|
14565
14738
|
generation,
|
|
14566
14739
|
rows,
|
|
14567
14740
|
pageInfo,
|
|
14568
|
-
raw:
|
|
14741
|
+
raw: acquisitionContentRaw(query.resource, { rows, truncated: raw.meta.truncated,
|
|
14742
|
+
hasNextPage: pageInfo?.hasNextPage, notes: securityNotes, blind: raw.securityBlind }),
|
|
14569
14743
|
contentDigest,
|
|
14570
14744
|
entities,
|
|
14571
14745
|
lastSuccessAt: raw.lastSuccessAt,
|
|
@@ -14574,7 +14748,7 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
|
|
|
14574
14748
|
hold: null,
|
|
14575
14749
|
capabilities,
|
|
14576
14750
|
meta: { at: raw.meta.at, truncated: raw.meta.truncated },
|
|
14577
|
-
securityNotes
|
|
14751
|
+
securityNotes,
|
|
14578
14752
|
securityBlind: raw.securityBlind,
|
|
14579
14753
|
};
|
|
14580
14754
|
}
|
|
@@ -16465,10 +16639,14 @@ function cleanupQueueForResource(queue, resource) {
|
|
|
16465
16639
|
return count;
|
|
16466
16640
|
}
|
|
16467
16641
|
|
|
16642
|
+
function recoveryResourceLabel(resource) {
|
|
16643
|
+
return resource === "graphql" ? "GraphQL" : resource === "core" ? "Core" : "GitHub";
|
|
16644
|
+
}
|
|
16645
|
+
|
|
16468
16646
|
function presentRecovery(cause, { nowMs = Date.now(), cols = 80, hasSnapshot } = {}) {
|
|
16469
16647
|
if (!cause) return [];
|
|
16470
16648
|
const width = Math.max(1, Number.isSafeInteger(cols) ? cols - 1 : 79);
|
|
16471
|
-
const resource = cause.resource
|
|
16649
|
+
const resource = recoveryResourceLabel(cause.resource);
|
|
16472
16650
|
const age = hasSnapshot === false ? "Not checked yet"
|
|
16473
16651
|
: Number.isFinite(cause.sourceAt) && cause.sourceAt <= nowMs
|
|
16474
16652
|
? `cached ${formatDuration(nowMs - cause.sourceAt)}`
|
|
@@ -16650,6 +16828,7 @@ function contextHelpPages({ view, target, tab, remoteSetup = false }, keys = key
|
|
|
16650
16828
|
issues: "Issues shows open issues in the selected repository.",
|
|
16651
16829
|
prs: "Pull requests shows open requests to merge changes into the selected repository.",
|
|
16652
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.",
|
|
16653
16832
|
};
|
|
16654
16833
|
const title = remoteSetup ? "No GitHub remote found" : view.title;
|
|
16655
16834
|
const action = remoteSetup ? "Close help with ?, then Enter to start gh repo create; or restart with gh-glance --repo owner/name."
|
|
@@ -16812,6 +16991,587 @@ function safeRecordRecoveryTransition(scope, event, write = recordRecoveryTransi
|
|
|
16812
16991
|
try { return write(scope, event); } catch { return { ok: false, reason: "unavailable" }; }
|
|
16813
16992
|
}
|
|
16814
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
|
+
|
|
16815
17575
|
function recoveryLedgerSummary(scope, resource, nowMs = Date.now()) {
|
|
16816
17576
|
if (!scope?.path || !RATE_RESOURCES.includes(resource)) return null;
|
|
16817
17577
|
const loaded = readGovernorState(scope.path, nowMs, { persistMigration: false });
|
|
@@ -17219,63 +17979,87 @@ const MIN_TABLE_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.header)));
|
|
|
17219
17979
|
// status line survive at any width a terminal is realistically set to.
|
|
17220
17980
|
const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactHeader)));
|
|
17221
17981
|
|
|
17222
|
-
// The tab bar is laid out independently of the table
|
|
17223
|
-
//
|
|
17982
|
+
// The tab bar is laid out independently of the table. Full labels for the five
|
|
17983
|
+
// sections need ~97 columns; short labels ~77. Hysteresis on the full/short
|
|
17224
17984
|
// breakpoint so a pane parked exactly on it does not flip labels on every
|
|
17225
|
-
// resize event and defeat the unchanged-frame optimization.
|
|
17226
|
-
|
|
17985
|
+
// resize event and defeat the unchanged-frame optimization. Below the short
|
|
17986
|
+
// width the bar drops names and keeps digits and counts (see tabBarEntries).
|
|
17987
|
+
const TAB_LABEL_FULL_WIDTH = 98;
|
|
17227
17988
|
const TAB_LABEL_HYSTERESIS = 4;
|
|
17989
|
+
const NOTIFICATIONS_LABEL = Object.freeze({ full: "Notifications", short: "Notices" });
|
|
17228
17990
|
|
|
17229
17991
|
function tabFailureSuffix({ count, failed, brokenCI }) {
|
|
17230
17992
|
if (count == null) return failed ? " x" : "";
|
|
17231
17993
|
return ` (${count}${brokenCI ? "!" : ""}${failed ? "x" : ""})`;
|
|
17232
17994
|
}
|
|
17233
17995
|
|
|
17234
|
-
function
|
|
17996
|
+
function tabBarWidth(entries) {
|
|
17997
|
+
return entries.reduce((sum, entry) => sum + entry.label.length + entry.margin, 0);
|
|
17998
|
+
}
|
|
17999
|
+
|
|
18000
|
+
// Four data sections plus Notifications on one row. The widest tier that fits
|
|
18001
|
+
// `cols` wins: full names, short names, digits with counts, the same without
|
|
18002
|
+
// gaps, then bare digits that keep only the Notifications badge -- so every
|
|
18003
|
+
// section key stays visible, and the row never wraps, down to a ~20-column pane.
|
|
18004
|
+
function tabBarEntries({ cols, counts, brokenCI, firstLoad, failed, spin, useShort, activeIndex,
|
|
18005
|
+
notifications = { unread: 0, tone: "none" } }) {
|
|
18006
|
+
const sections = TABS.map((tab) => {
|
|
18007
|
+
const count = counts[tab.key];
|
|
18008
|
+
// A tab that has never resolved shows the spinner where its count will
|
|
18009
|
+
// go, so the first load reads as "working" rather than "empty".
|
|
18010
|
+
const suffix = count == null && firstLoad[tab.key] ? ` ${spin}`
|
|
18011
|
+
: tabFailureSuffix({ count, failed: failed[tab.key], brokenCI: count == null ? false : brokenCI[tab.key] });
|
|
18012
|
+
// A tab whose fetches are failing kept rendering its last good count at
|
|
18013
|
+
// full confidence, and the error banner is only visible on the tab you
|
|
18014
|
+
// have selected -- so a background tab could fail for an hour with
|
|
18015
|
+
// nothing on screen to say so.
|
|
18016
|
+
return { key: tab.key, full: tab.label, short: tab.short, suffix,
|
|
18017
|
+
color: failed[tab.key] ? "redBright" : undefined, quiet: !failed[tab.key] };
|
|
18018
|
+
});
|
|
18019
|
+
const { unread, tone } = notifications;
|
|
18020
|
+
sections.push({ key: "notifications", ...NOTIFICATIONS_LABEL,
|
|
18021
|
+
suffix: unread > 0 ? ` (${unread})` : "",
|
|
18022
|
+
color: tone === "red" ? "redBright" : tone === "yellow" ? ATTENTION : undefined,
|
|
18023
|
+
quiet: tone !== "red" && tone !== "yellow" });
|
|
18024
|
+
const build = (tier) => sections.map((section, index) => {
|
|
18025
|
+
const active = index === activeIndex;
|
|
18026
|
+
const named = tier === "full" || tier === "short";
|
|
18027
|
+
const suffix = tier === "digits" && section.key !== "notifications" ? "" : section.suffix;
|
|
18028
|
+
const body = named ? `${index + 1}:${section[tier]}${suffix}` : `${index + 1}${suffix.trimStart()}`;
|
|
18029
|
+
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
18030
|
+
// dumb terminal), which left no indication at all of which tab was
|
|
18031
|
+
// selected. Brackets survive; the padding they replace keeps the width
|
|
18032
|
+
// identical, so the bar does not shift between the two modes.
|
|
18033
|
+
return { key: section.key, active, label: active ? `[${body}]` : ` ${body} `,
|
|
18034
|
+
margin: index === sections.length - 1 || tier === "tight" || tier === "digits" ? 0 : tier === "full" ? 2 : 1,
|
|
18035
|
+
color: section.color, dim: !active && section.quiet };
|
|
18036
|
+
});
|
|
18037
|
+
for (const tier of [...useShort ? [] : ["full"], "short", "compact", "tight"]) {
|
|
18038
|
+
const entries = build(tier);
|
|
18039
|
+
if (tabBarWidth(entries) <= cols) return entries;
|
|
18040
|
+
}
|
|
18041
|
+
return build("digits");
|
|
18042
|
+
}
|
|
18043
|
+
|
|
18044
|
+
function TabBar({ entries }) {
|
|
17235
18045
|
return e(
|
|
17236
18046
|
Box,
|
|
17237
18047
|
{ flexDirection: "row" },
|
|
17238
|
-
...
|
|
17239
|
-
|
|
17240
|
-
|
|
17241
|
-
|
|
17242
|
-
|
|
17243
|
-
|
|
17244
|
-
|
|
17245
|
-
|
|
17246
|
-
|
|
17247
|
-
|
|
17248
|
-
:
|
|
17249
|
-
|
|
17250
|
-
|
|
17251
|
-
|
|
17252
|
-
|
|
17253
|
-
const name = useShort ? tab.short : tab.label;
|
|
17254
|
-
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
17255
|
-
// dumb terminal), which left no indication at all of which tab was
|
|
17256
|
-
// selected. Brackets survive; the padding they replace keeps the width
|
|
17257
|
-
// identical, so the bar does not shift between the two modes.
|
|
17258
|
-
const label = active ? `[${i + 1}:${name}${suffix}]` : ` ${i + 1}:${name}${suffix} `;
|
|
17259
|
-
return e(
|
|
17260
|
-
Box,
|
|
17261
|
-
{ key: tab.key, marginRight: 2 },
|
|
17262
|
-
e(
|
|
17263
|
-
Text,
|
|
17264
|
-
{
|
|
17265
|
-
bold: active,
|
|
17266
|
-
inverse: active,
|
|
17267
|
-
// A tab whose fetches are failing kept rendering its last good
|
|
17268
|
-
// count at full confidence, and the error banner is only visible on
|
|
17269
|
-
// the tab you have selected -- so a background tab could fail for an
|
|
17270
|
-
// hour with nothing on screen to say so.
|
|
17271
|
-
color: failed[tab.key] ? "redBright" : undefined,
|
|
17272
|
-
dimColor: !active && !failed[tab.key],
|
|
17273
|
-
wrap: "truncate-end",
|
|
17274
|
-
},
|
|
17275
|
-
label,
|
|
17276
|
-
),
|
|
17277
|
-
);
|
|
17278
|
-
}),
|
|
18048
|
+
...entries.map((entry) => e(
|
|
18049
|
+
Box,
|
|
18050
|
+
{ key: entry.key, marginRight: entry.margin },
|
|
18051
|
+
e(
|
|
18052
|
+
Text,
|
|
18053
|
+
{
|
|
18054
|
+
bold: entry.active,
|
|
18055
|
+
inverse: entry.active,
|
|
18056
|
+
color: entry.color,
|
|
18057
|
+
dimColor: entry.dim,
|
|
18058
|
+
wrap: "truncate-end",
|
|
18059
|
+
},
|
|
18060
|
+
entry.label,
|
|
18061
|
+
),
|
|
18062
|
+
)),
|
|
17279
18063
|
);
|
|
17280
18064
|
}
|
|
17281
18065
|
|
|
@@ -18472,6 +19256,108 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18472
19256
|
const { isRawModeSupported } = useStdin();
|
|
18473
19257
|
const { exit, suspendTerminal } = useApp();
|
|
18474
19258
|
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
19259
|
+
// Notifications is a view, not a data tab: `activeIndex` keeps naming the
|
|
19260
|
+
// last data tab, so polling, cache and per-tab state never see a fifth key.
|
|
19261
|
+
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
|
19262
|
+
const notificationsOpenRef = useRef(notificationsOpen);
|
|
19263
|
+
notificationsOpenRef.current = notificationsOpen;
|
|
19264
|
+
const [notificationsOffset, setNotificationsOffset] = useState(0);
|
|
19265
|
+
const notificationLogRef = useRef(null);
|
|
19266
|
+
notificationLogRef.current ??= createNotificationLog();
|
|
19267
|
+
// Only the badge lives in state, and it changes only when the unread count or
|
|
19268
|
+
// tone does: routine refreshes never re-render a closed view, which keeps an
|
|
19269
|
+
// idle dashboard's frames byte-identical.
|
|
19270
|
+
const [notificationBadge, setNotificationBadge] = useState(() => notificationLogRef.current.summary());
|
|
19271
|
+
const notificationBadgeRef = useRef(notificationBadge);
|
|
19272
|
+
const [, setNotificationVersion] = useState(0);
|
|
19273
|
+
const notificationSnapshotRef = useRef(null);
|
|
19274
|
+
const notificationDiffRef = useRef(null);
|
|
19275
|
+
const notificationTimerRef = useRef(null);
|
|
19276
|
+
const syncNotificationsRef = useRef(null);
|
|
19277
|
+
const notificationInputsRef = useRef(null);
|
|
19278
|
+
syncNotificationsRef.current = ({ force = false } = {}) => {
|
|
19279
|
+
const log = notificationLogRef.current;
|
|
19280
|
+
const nowMs = Date.now();
|
|
19281
|
+
// Spinner and clock renders change none of the inputs; skip their diff.
|
|
19282
|
+
const inputs = notificationSnapshotRef.current.inputs;
|
|
19283
|
+
const previousInputs = notificationInputsRef.current;
|
|
19284
|
+
const unchanged = !force && previousInputs?.length === inputs.length &&
|
|
19285
|
+
inputs.every((value, index) => Object.is(value, previousInputs[index]));
|
|
19286
|
+
notificationInputsRef.current = inputs;
|
|
19287
|
+
const result = unchanged ? { begin: [], end: [], pending: notificationTimerRef.current?.due ?? null }
|
|
19288
|
+
: diffNotificationSnapshots(notificationDiffRef.current, notificationSnapshotRef.current, nowMs);
|
|
19289
|
+
if (!unchanged) notificationDiffRef.current = result.state;
|
|
19290
|
+
for (const event of result.begin) log.record(event);
|
|
19291
|
+
for (const key of result.end) log.end(key, nowMs);
|
|
19292
|
+
// A condition can become due with nothing else re-rendering the pane.
|
|
19293
|
+
if ((notificationTimerRef.current?.due ?? null) !== result.pending) {
|
|
19294
|
+
clearTimeout(notificationTimerRef.current?.id);
|
|
19295
|
+
notificationTimerRef.current = result.pending === null ? null : {
|
|
19296
|
+
due: result.pending,
|
|
19297
|
+
id: setTimeout(() => {
|
|
19298
|
+
notificationTimerRef.current = null;
|
|
19299
|
+
syncNotificationsRef.current?.({ force: true });
|
|
19300
|
+
}, Math.max(0, result.pending - nowMs)),
|
|
19301
|
+
};
|
|
19302
|
+
notificationTimerRef.current?.id.unref?.();
|
|
19303
|
+
}
|
|
19304
|
+
if (notificationsOpenRef.current && !showHelpRef.current) {
|
|
19305
|
+
log.markSeen();
|
|
19306
|
+
if (result.begin.length > 0 || result.end.length > 0) setNotificationVersion((version) => version + 1);
|
|
19307
|
+
}
|
|
19308
|
+
// A failing tab is the moment GitHub's own status is worth a look.
|
|
19309
|
+
if (result.begin.some((event) => event.level === "problem" || event.code === "network-outage")) {
|
|
19310
|
+
void githubStatusRef.current?.checkNow();
|
|
19311
|
+
}
|
|
19312
|
+
const badge = log.summary();
|
|
19313
|
+
if (badge.unread !== notificationBadgeRef.current.unread || badge.tone !== notificationBadgeRef.current.tone) {
|
|
19314
|
+
notificationBadgeRef.current = badge;
|
|
19315
|
+
setNotificationBadge(badge);
|
|
19316
|
+
}
|
|
19317
|
+
};
|
|
19318
|
+
useEffect(() => { syncNotificationsRef.current(); });
|
|
19319
|
+
const notificationPageRef = useRef({ pageRows: 1, maxOffset: 0 });
|
|
19320
|
+
// GitHub platform status: started only here, inside the mounted dashboard,
|
|
19321
|
+
// so --doctor, --version, --help and other non-TUI paths never request it.
|
|
19322
|
+
const githubStatusRef = useRef(null);
|
|
19323
|
+
const githubStatusConfigRef = useRef(null);
|
|
19324
|
+
githubStatusConfigRef.current ??= githubStatusConfig(process.env, effectiveRuntimeHost());
|
|
19325
|
+
const [githubStatus, setGithubStatus] = useState(() => ({ enabled: githubStatusConfigRef.current.enabled,
|
|
19326
|
+
reason: githubStatusConfigRef.current.reason, summary: null, unavailable: false, nextAt: null, outage: false }));
|
|
19327
|
+
useEffect(() => {
|
|
19328
|
+
const monitor = createGitHubStatusMonitor({ config: githubStatusConfigRef.current,
|
|
19329
|
+
log: notificationLogRef.current,
|
|
19330
|
+
onChange: () => setGithubStatus(monitor.current()) });
|
|
19331
|
+
githubStatusRef.current = monitor;
|
|
19332
|
+
return () => {
|
|
19333
|
+
monitor.stop();
|
|
19334
|
+
githubStatusRef.current = null;
|
|
19335
|
+
};
|
|
19336
|
+
}, []);
|
|
19337
|
+
const openNotifications = () => {
|
|
19338
|
+
notificationLogRef.current.markSeen();
|
|
19339
|
+
setNotificationsOffset(0);
|
|
19340
|
+
setNotificationsOpen(true);
|
|
19341
|
+
void githubStatusRef.current?.checkNow();
|
|
19342
|
+
};
|
|
19343
|
+
// Five positions: the four data tabs, then Notifications.
|
|
19344
|
+
const selectPosition = (position) => {
|
|
19345
|
+
if (position === TABS.length) {
|
|
19346
|
+
if (!notificationsOpenRef.current) openNotifications();
|
|
19347
|
+
return;
|
|
19348
|
+
}
|
|
19349
|
+
setNotificationsOpen(false);
|
|
19350
|
+
setActiveIndex(position);
|
|
19351
|
+
};
|
|
19352
|
+
const stepPosition = (direction) => {
|
|
19353
|
+
const positions = TABS.length + 1;
|
|
19354
|
+
const current = notificationsOpenRef.current ? TABS.length : activeIndexRef.current;
|
|
19355
|
+
selectPosition((current + direction + positions) % positions);
|
|
19356
|
+
};
|
|
19357
|
+
useEffect(() => () => {
|
|
19358
|
+
clearTimeout(notificationTimerRef.current?.id);
|
|
19359
|
+
syncNotificationsRef.current = null;
|
|
19360
|
+
}, []);
|
|
18475
19361
|
const [preferencePath] = useState(() => widthPreferencesPath());
|
|
18476
19362
|
const [loadedWidthPreferences] = useState(() => loadWidthPreferences(preferencePath));
|
|
18477
19363
|
const [widthOverrides, setWidthOverrides] = useState(() => loadedWidthPreferences.preferences);
|
|
@@ -18525,6 +19411,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18525
19411
|
host: runtimeIdentityCoordinator?.current()?.host ?? effectiveRuntimeHost(),
|
|
18526
19412
|
cwd: process.cwd(),
|
|
18527
19413
|
account: sshCacheIdentity ?? runtimeIdentityCoordinator?.current()?.accessKey ?? "unverified",
|
|
19414
|
+
remoteUrls: runtimeRemoteUrls,
|
|
18528
19415
|
}),
|
|
18529
19416
|
);
|
|
18530
19417
|
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
@@ -18865,14 +19752,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18865
19752
|
const noticeCause = activeRecovery ?? (noticeReason ? recoveryCause({
|
|
18866
19753
|
reason: noticeReason === "other" ? "network-outage" : noticeReason,
|
|
18867
19754
|
resource: noticeResource, sourceAt: noticeSourceAt,
|
|
18868
|
-
retryAt: activeGovernorDecision
|
|
18869
|
-
activeGovernorDecision?.notBefore ?? null,
|
|
19755
|
+
retryAt: decisionRetryAt(activeGovernorDecision),
|
|
18870
19756
|
origin: tabError ? "source" : "coordination", tab: tabError ? tab.key : null,
|
|
18871
19757
|
}) : null);
|
|
18872
|
-
const recoveryLines = showCoordinationNotice && noticeCause
|
|
18873
|
-
? presentRecovery(noticeCause, { nowMs: now.getTime(), cols, hasSnapshot: items !== null })
|
|
18874
|
-
: [!remoteSetup && displayError ? displayError : ""];
|
|
18875
|
-
const noticeTone = showCoordinationNotice ? ATTENTION : displayError ? ERROR_TEXT : undefined;
|
|
18876
19758
|
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
18877
19759
|
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
18878
19760
|
// every pane is plausible and wrong. Threshold-gated and minute-granular on
|
|
@@ -18970,34 +19852,74 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18970
19852
|
: ['CI evidence',...ciLines].flatMap(line=>wrapPanelText(line,Math.max(1,cols-5))).slice(0,Math.max(1,rows-6))
|
|
18971
19853
|
: contextualPanelLayout(presentation, { cols, rows });
|
|
18972
19854
|
|
|
19855
|
+
// One snapshot per render feeds the notification history; diffing it in an
|
|
19856
|
+
// effect avoids instrumenting every error, recovery and Security-note writer.
|
|
19857
|
+
const notificationDisconnected = statusDisconnected ? runtime.connect === null ? "identity-unavailable"
|
|
19858
|
+
: activeGovernorDecision?.reason === "app-auth" ? "app-auth" : "disconnected" : null;
|
|
19859
|
+
notificationSnapshotRef.current = {
|
|
19860
|
+
// Everything the snapshot is built from, compared by identity before diffing.
|
|
19861
|
+
inputs: [errors, acquisitionFailures, governorDecisions, recoveryCauses, securityNotes, failureContext,
|
|
19862
|
+
notificationDisconnected, ...TABS.map(({ key }) => lastOkRef.current[key] ?? null)],
|
|
19863
|
+
tabs: Object.fromEntries(TABS.map(({ key }) => {
|
|
19864
|
+
const decision = governorDecisions[key];
|
|
19865
|
+
return [key, { lastOk: lastOkRef.current[key] ?? null, error: errors[key],
|
|
19866
|
+
failure: acquisitionFailures[key] ?? null,
|
|
19867
|
+
paused: decision?.mode === "paused" || decision?.coordinationError ? decision.reason ?? null : null }];
|
|
19868
|
+
})),
|
|
19869
|
+
recovery: recoveryCauses,
|
|
19870
|
+
securityNotes,
|
|
19871
|
+
failureContext,
|
|
19872
|
+
disconnected: notificationDisconnected,
|
|
19873
|
+
};
|
|
19874
|
+
|
|
18973
19875
|
const tableVisible = !ciView && !attentionView && Array.isArray(items) && items.length > 0;
|
|
18974
19876
|
const tableNoticeCapacity = Math.max(0, rows - 9);
|
|
18975
|
-
|
|
18976
|
-
|
|
18977
|
-
|
|
18978
|
-
|
|
18979
|
-
|
|
18980
|
-
|
|
18981
|
-
: showCoordinationNotice && noticeCause
|
|
18982
|
-
? [noticeCause.summary, shortAction, presentation.sourceCheck]
|
|
18983
|
-
: [shortAction, presentation.sourceCheck, presentation.title]
|
|
18984
|
-
: [presentation.title, presentation.sourceCheck, ...recoveryLines.filter(Boolean)]
|
|
18985
|
-
: recoveryLines;
|
|
19877
|
+
// Only what needs the user's hand takes rows from a visible table; every
|
|
19878
|
+
// other status event is in the Notifications view.
|
|
19879
|
+
const critical = criticalInlineLines({ cause: noticeCause, showCause: showCoordinationNotice,
|
|
19880
|
+
tabError: remoteSetup ? null : tabError, displayError, capacity: tableNoticeCapacity,
|
|
19881
|
+
nowMs: now.getTime(), cols, hasSnapshot: items !== null });
|
|
19882
|
+
const noticeTone = critical.source === "cause" ? ATTENTION : critical.source === "error" ? ERROR_TEXT : undefined;
|
|
18986
19883
|
const featureLines = focus ? [presentation.title, ...(focus.error ? [focus.error] : []),
|
|
18987
19884
|
...focusDisclosureLines(focusMessage,focus.semantic?.message).flatMap(line=>line.split(/(?<=\.) /u)), ...featureKeyLines(focus)]
|
|
18988
19885
|
.flatMap(line=>wrapPanelText(line, Math.max(1,cols-5))) : [];
|
|
18989
|
-
const noticeLines = [...new Set([...(focus ? featureLines : []), ...
|
|
19886
|
+
const noticeLines = [...new Set([...(focus ? featureLines : []), ...critical.lines,
|
|
18990
19887
|
...(setupView?.title ? [setupView.title, setupView.action, ...setupView.notes] : []),
|
|
18991
|
-
...
|
|
18992
|
-
const
|
|
18993
|
-
? securityLines.slice(0, Math.max(0, tableNoticeCapacity - noticeLines.length)) : [];
|
|
18994
|
-
const extraLines = !showHelp && !remoteSetup && tableVisible
|
|
18995
|
-
? noticeLines.length + visibleSecurityLines.length : 0;
|
|
19888
|
+
...(!focus && featureNotice ? [featureNotice] : [])])].filter(Boolean).slice(0, tableNoticeCapacity);
|
|
19889
|
+
const extraLines = !showHelp && !remoteSetup && tableVisible ? noticeLines.length : 0;
|
|
18996
19890
|
// Tab strip, divider, frame edges, footer and terminal guard use six rows.
|
|
18997
19891
|
// Table headers alone need two more. The same budget drives navigation.
|
|
18998
19892
|
const bodyRows = Math.max(1, rows - 6 - (tableVisible && !showHelp ? 2 : 0) - extraLines);
|
|
19893
|
+
const notificationRows = Math.max(1, rows - 6);
|
|
19894
|
+
const notificationStatusLine = notificationsOpen ? githubStatusLine(githubStatus, now.getTime()) : "";
|
|
19895
|
+
let notificationView = null;
|
|
19896
|
+
if (notificationsOpen) {
|
|
19897
|
+
// Open waits show when they retry next, from the live cause or hold.
|
|
19898
|
+
const liveRetry = {};
|
|
19899
|
+
for (const cause of Object.values(recoveryCauses)) {
|
|
19900
|
+
if (cause?.code && Number.isFinite(cause.retryAt)) liveRetry[cause.code] = cause.retryAt;
|
|
19901
|
+
}
|
|
19902
|
+
for (const decision of Object.values(governorDecisions)) {
|
|
19903
|
+
const retryAt = decisionRetryAt(decision);
|
|
19904
|
+
if (decision?.reason && Number.isFinite(retryAt)) liveRetry[decision.reason] ??= retryAt;
|
|
19905
|
+
}
|
|
19906
|
+
notificationView = notificationViewLines({
|
|
19907
|
+
entries: notificationLogRef.current.list(), statusLine: notificationStatusLine, nowMs: now.getTime(),
|
|
19908
|
+
capacity: notificationRows, offset: notificationsOffset, liveRetry,
|
|
19909
|
+
});
|
|
19910
|
+
}
|
|
19911
|
+
notificationPageRef.current = { pageRows: notificationView?.pageRows ?? 1,
|
|
19912
|
+
maxOffset: notificationView?.maxOffset ?? 0 };
|
|
18999
19913
|
let helpPages = [];
|
|
19000
|
-
if (showHelp) {
|
|
19914
|
+
if (showHelp && notificationsOpen) {
|
|
19915
|
+
helpPages = contextHelpPages({ view: {
|
|
19916
|
+
title: NOTIFICATIONS_LABEL.full,
|
|
19917
|
+
action: "Press 1-4 or Esc to return to a section; Up/Down scroll; r checks GitHub status.",
|
|
19918
|
+
sourceCheck: notificationStatusLine,
|
|
19919
|
+
explanation: "Lines above a table appear only when you must act. Waits, refreshes, failed refreshes and cleared causes are listed here, newest first.",
|
|
19920
|
+
notes: [],
|
|
19921
|
+
}, target: selectedTarget, tab: "notifications" }, keyTableLines(), cols - 5, bodyRows);
|
|
19922
|
+
} else if (showHelp) {
|
|
19001
19923
|
const fullDiagnostic = formatTabError(tabError, failureContext);
|
|
19002
19924
|
const helpView = { ...presentation, notes: [
|
|
19003
19925
|
...(fullDiagnostic ? [fullDiagnostic] : []),
|
|
@@ -19434,6 +20356,33 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19434
20356
|
}
|
|
19435
20357
|
// Width mode owns every other key. In particular, digits, arrows, Tab,
|
|
19436
20358
|
// Enter and r must never fall through to their ordinary meanings.
|
|
20359
|
+
} else if (notificationsOpenRef.current && !showHelpRef.current) {
|
|
20360
|
+
// The view owns its keys; data-tab actions (Enter, /, s, g, w, d, a)
|
|
20361
|
+
// have nothing to act on here and are ignored rather than applied to
|
|
20362
|
+
// the hidden tab.
|
|
20363
|
+
const { pageRows, maxOffset } = notificationPageRef.current;
|
|
20364
|
+
if (input === "?") {
|
|
20365
|
+
setHelpPage(0);
|
|
20366
|
+
setShowHelp(true);
|
|
20367
|
+
} else if (key.escape) {
|
|
20368
|
+
setNotificationsOpen(false);
|
|
20369
|
+
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
|
|
20370
|
+
selectPosition(Number(input) - 1);
|
|
20371
|
+
} else if (key.tab && key.shift || key.leftArrow) {
|
|
20372
|
+
stepPosition(-1);
|
|
20373
|
+
} else if (key.tab || key.rightArrow) {
|
|
20374
|
+
stepPosition(1);
|
|
20375
|
+
} else if (key.downArrow || input === "j" || key.upArrow || input === "k" || key.pageDown || key.pageUp) {
|
|
20376
|
+
const step = key.pageDown ? pageRows : key.pageUp ? -pageRows
|
|
20377
|
+
: key.downArrow || input === "j" ? 1 : -1;
|
|
20378
|
+
setNotificationsOffset((current) => Math.max(0, Math.min(maxOffset, current + step)));
|
|
20379
|
+
} else if (input === "r" || input === "R") {
|
|
20380
|
+
// In this view, r asks GitHub's status page, not the hidden data tab.
|
|
20381
|
+
void githubStatusRef.current?.checkNow();
|
|
20382
|
+
}
|
|
20383
|
+
} else if (key.escape && showHelpRef.current && notificationsOpenRef.current) {
|
|
20384
|
+
// The view's help says Esc returns; it must not quit from here.
|
|
20385
|
+
setShowHelp(false);
|
|
19437
20386
|
} else if (key.escape && showHelpRef.current && featureRef.current) {
|
|
19438
20387
|
setShowHelp(false);
|
|
19439
20388
|
} else if (key.escape && featureRef.current?.semantic?.consent) {
|
|
@@ -19522,6 +20471,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19522
20471
|
} else if (input==='d' && TABS[activeIndexRef.current].key==='actions') {
|
|
19523
20472
|
void ciEvidenceRef.current?.('open');
|
|
19524
20473
|
} else if (featureRef.current?.mode==='ci' && input==='r') {
|
|
20474
|
+
retryPausedTransport(runtimeIdentityCoordinator);
|
|
19525
20475
|
ciControllerRef.current?.abort();attentionControllerRef.current?.abort();jevOperationRef.current=null;runtimeJevSession?.clear();setFeature(null);void fetchTabRef.current?.('actions',manualRefreshRequest(refreshIntent(input)));
|
|
19526
20476
|
} else if (featureRef.current?.mode==='focus' && input==='x') {
|
|
19527
20477
|
jevOperationRef.current=null;const current=featureRef.current;
|
|
@@ -19570,15 +20520,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19570
20520
|
// validators: `r` on a quiet repository answers 304 and costs nothing,
|
|
19571
20521
|
// which is exactly when the key is most likely to be pressed.
|
|
19572
20522
|
const request = manualRefreshRequest(refreshIntent(input));
|
|
20523
|
+
// The explicit retry a paused transport waits for. A collector client has
|
|
20524
|
+
// no local coordinator; its collector resumes on the refresh it receives.
|
|
20525
|
+
if (request) retryPausedTransport(runtimeIdentityCoordinator);
|
|
19573
20526
|
if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
|
|
19574
|
-
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
|
|
19575
|
-
|
|
19576
|
-
} else if (key.tab && key.shift) {
|
|
19577
|
-
|
|
20527
|
+
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length + 1)) {
|
|
20528
|
+
selectPosition(Number(input) - 1);
|
|
20529
|
+
} else if (key.tab && key.shift || key.leftArrow) {
|
|
20530
|
+
stepPosition(-1);
|
|
19578
20531
|
} else if (key.tab || key.rightArrow) {
|
|
19579
|
-
|
|
19580
|
-
} else if (key.leftArrow) {
|
|
19581
|
-
setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
|
|
20532
|
+
stepPosition(1);
|
|
19582
20533
|
}
|
|
19583
20534
|
},
|
|
19584
20535
|
// `isActive` has to be coerced: ink only skips raw mode when the flag is
|
|
@@ -20054,6 +21005,17 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20054
21005
|
setSecurityBlind(true);
|
|
20055
21006
|
return;
|
|
20056
21007
|
}
|
|
21008
|
+
// Every Security source replayed its capability backoff: no request
|
|
21009
|
+
// was made, so nothing was observed. Keep the displayed rows, the
|
|
21010
|
+
// freshness clock and the peers' snapshot as they are (plan D6), and
|
|
21011
|
+
// let the ordinary cadence retry. The request itself is answered.
|
|
21012
|
+
if (result?.sourceObserved === false) {
|
|
21013
|
+
await cancelAcquisition(acquisition);
|
|
21014
|
+
requestedTabs.delete(key);
|
|
21015
|
+
rescheduleTab(key, completedAt);
|
|
21016
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
21017
|
+
return;
|
|
21018
|
+
}
|
|
20057
21019
|
// The shared nonce/generation is the publication fence. A superseded
|
|
20058
21020
|
// owner must not update any local freshness, rows, cadence, or cache.
|
|
20059
21021
|
const publicationView = stagedAcquisitionPublicationView({
|
|
@@ -20152,7 +21114,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20152
21114
|
const recovery = requestFailureRecovery(err, verdict);
|
|
20153
21115
|
recordRuntimeRecovery(key, recovery.reason, { origin: recovery.origin, retryAt });
|
|
20154
21116
|
}
|
|
20155
|
-
|
|
21117
|
+
// A secondary limit is held by the shared transport cooldown, not by a
|
|
21118
|
+
// resource block until the primary reset.
|
|
21119
|
+
if (verdict === "rate-limited" && !secondaryThrottleEvidence(err)) {
|
|
20156
21120
|
const snapshot = inspectGovernor(scope, Date.now());
|
|
20157
21121
|
const costs = tabRequestCost(key);
|
|
20158
21122
|
const blocks = [];
|
|
@@ -20237,6 +21201,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20237
21201
|
const inFlightStartedAt = {};
|
|
20238
21202
|
const wakeScheduler = createWakeScheduler();
|
|
20239
21203
|
let scope = null;
|
|
21204
|
+
let notificationsSeeded = false;
|
|
20240
21205
|
let scopeFailure = null;
|
|
20241
21206
|
let cleanupScope = null;
|
|
20242
21207
|
let registeredScopeHash = null;
|
|
@@ -20304,6 +21269,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20304
21269
|
entityRef.current.set(entity.key, { etag: entity.etag, body: entity.body });
|
|
20305
21270
|
}
|
|
20306
21271
|
const sameRows = JSON.stringify(dataRef.current[key]) === JSON.stringify(snapshot.rows);
|
|
21272
|
+
const sameContent = sameRows && (key !== "security" ||
|
|
21273
|
+
JSON.stringify(securityNotesRef.current) === JSON.stringify(snapshot.securityNotes ?? []) &&
|
|
21274
|
+
securityBlindRef.current === (snapshot.securityBlind === true));
|
|
21275
|
+
rawRef.current[key] = adoptedAcquisitionRaw(rawRef.current[key], snapshot, sameContent);
|
|
20307
21276
|
if (!sameRows) setData((current) => ({ ...current, [key]: snapshot.rows }));
|
|
20308
21277
|
setMeta((current) => current[key]?.at === snapshotMeta.at &&
|
|
20309
21278
|
current[key]?.truncated === snapshotMeta.truncated
|
|
@@ -20312,7 +21281,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20312
21281
|
const accessKey = identity()?.accessKey;
|
|
20313
21282
|
if (accessKey) {
|
|
20314
21283
|
for (const [sourceKey, capability] of Object.entries(snapshot.capabilities ?? {})) {
|
|
20315
|
-
alertBackoff.set(
|
|
21284
|
+
alertBackoff.set(backoffStorageKey(sourceKey, accessKey), {
|
|
20316
21285
|
...capability,
|
|
20317
21286
|
until: performance.now() + Math.max(0, capability.until - Date.now()),
|
|
20318
21287
|
});
|
|
@@ -20860,10 +21829,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20860
21829
|
scope = createQuotaScope(current, { root: runtimeIdentityCoordinator.root, identityProvider: identity });
|
|
20861
21830
|
cleanupScope = { ...scope, identityProvider: null };
|
|
20862
21831
|
const savedRecovery = readRecoveryTransitions(scope);
|
|
21832
|
+
// Earlier transitions become dimmed history, once per pane, from the
|
|
21833
|
+
// journal just read; collector clients have no journal and start empty.
|
|
21834
|
+
if (!notificationsSeeded) {
|
|
21835
|
+
notificationsSeeded = true;
|
|
21836
|
+
seedNotificationsFromJournal(notificationLogRef.current, scope, () => savedRecovery);
|
|
21837
|
+
}
|
|
20863
21838
|
recoveryRef.current = savedRecovery.status === "healthy"
|
|
20864
21839
|
? { ...savedRecovery.current } : { core: null, graphql: null };
|
|
20865
21840
|
setRecoveryCauses(recoveryRef.current);
|
|
20866
|
-
const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey });
|
|
21841
|
+
const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey, remoteUrls: runtimeRemoteUrls });
|
|
20867
21842
|
// The mount-time target had to guess the account, and guesses
|
|
20868
21843
|
// "unverified" whenever the bounded warm resolution above did not land
|
|
20869
21844
|
// in time -- so a session that has rows on disk can start with none.
|
|
@@ -21985,6 +22960,21 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
21985
22960
|
return () => clearInterval(timer);
|
|
21986
22961
|
}, [showCachedAge]);
|
|
21987
22962
|
|
|
22963
|
+
// Collector mode returns from the acquisition effect before any standalone
|
|
22964
|
+
// wake can advance `now`, which froze running TIME and every UPDATED age
|
|
22965
|
+
// while connected. Apply the standalone rule from a display-only timer: every
|
|
22966
|
+
// second while a run is in progress, otherwise at most once a minute (a same
|
|
22967
|
+
// Date leaves React's render bail-out intact). It never feeds freshness.
|
|
22968
|
+
const collectorDisplayClock = runtime.connect !== null;
|
|
22969
|
+
useEffect(() => {
|
|
22970
|
+
if (!collectorDisplayClock) return;
|
|
22971
|
+
const timer = setInterval(() => setNow((previous) => {
|
|
22972
|
+
const at = Date.now();
|
|
22973
|
+
return hasInProgressRef.current || at - previous.getTime() >= 60_000 ? new Date(at) : previous;
|
|
22974
|
+
}), 1000);
|
|
22975
|
+
return () => clearInterval(timer);
|
|
22976
|
+
}, [collectorDisplayClock]);
|
|
22977
|
+
|
|
21988
22978
|
const allItems = items ?? [];
|
|
21989
22979
|
const tabOffsetRaw = offset[tab.key] ?? 0;
|
|
21990
22980
|
const selectedKey = selected[tab.key] ?? null;
|
|
@@ -22024,8 +23014,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22024
23014
|
}, [reconciledSelection.key, reconciledSelection.offset, selectedKey, tabOffsetRaw, tab.key]);
|
|
22025
23015
|
// Bottom-right of the frame, lazygit style: how much of the tab you can
|
|
22026
23016
|
// currently see out of how much there is.
|
|
22027
|
-
const
|
|
22028
|
-
|
|
23017
|
+
const notificationSummary = notificationsOpen ? notificationLogRef.current.summary() : null;
|
|
23018
|
+
const countLabel = notificationSummary
|
|
23019
|
+
? notificationSummary.truncated ? `Showing the latest ${NOTIFICATION_LOG_MAX} events`
|
|
23020
|
+
: `${notificationSummary.size} ${notificationSummary.size === 1 ? "event" : "events"}`
|
|
23021
|
+
: !tableVisible
|
|
22029
23022
|
? null
|
|
22030
23023
|
: tabOffset > 0
|
|
22031
23024
|
? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
|
|
@@ -22041,8 +23034,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22041
23034
|
|
|
22042
23035
|
// The existing resolver also supplies inferred targets without another lookup.
|
|
22043
23036
|
// Complete target and host remain reachable in help when this edge is short.
|
|
22044
|
-
const
|
|
22045
|
-
const
|
|
23037
|
+
const viewLabel = notificationsOpen ? NOTIFICATIONS_LABEL.full : tab.label;
|
|
23038
|
+
const withTarget = `${viewLabel} · ${selectedTarget.label}`;
|
|
23039
|
+
const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : viewLabel;
|
|
22046
23040
|
|
|
22047
23041
|
// Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
|
|
22048
23042
|
// at ordinary widths. Narrow panes drop columns instead, which keeps the
|
|
@@ -22131,13 +23125,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22131
23125
|
Box,
|
|
22132
23126
|
{ flexDirection: "column", width: frameCols, height: frameRows },
|
|
22133
23127
|
e(TabBar, {
|
|
22134
|
-
|
|
22135
|
-
|
|
22136
|
-
|
|
22137
|
-
|
|
22138
|
-
|
|
22139
|
-
|
|
22140
|
-
|
|
23128
|
+
entries: tabBarEntries({
|
|
23129
|
+
cols: frameCols,
|
|
23130
|
+
counts,
|
|
23131
|
+
brokenCI,
|
|
23132
|
+
firstLoad,
|
|
23133
|
+
failed,
|
|
23134
|
+
spin,
|
|
23135
|
+
useShort: useShortLabels,
|
|
23136
|
+
activeIndex: notificationsOpen ? TABS.length : activeIndex,
|
|
23137
|
+
// An active GitHub outage keeps the badge red even after it was read.
|
|
23138
|
+
notifications: githubStatus.outage ? { ...notificationBadge, tone: "red" } : notificationBadge,
|
|
23139
|
+
}),
|
|
22141
23140
|
}),
|
|
22142
23141
|
e(Divider, { width: frameCols }),
|
|
22143
23142
|
e(PanelEdge, { width: frameCols, top: true, label: topLabel, labelColor: TITLE_COLOR }),
|
|
@@ -22167,20 +23166,20 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22167
23166
|
showHelp &&
|
|
22168
23167
|
(helpPages[visibleHelpPage] ?? [])
|
|
22169
23168
|
.map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
|
|
23169
|
+
!showHelp && notificationView &&
|
|
23170
|
+
notificationView.lines.map((line, index) => e(Text, {
|
|
23171
|
+
key: `notification:${index}`, wrap: "truncate-end", dimColor: line.dim,
|
|
23172
|
+
color: line.level === "status" ? TITLE_COLOR : line.dim ? undefined
|
|
23173
|
+
: line.level === "wait" ? ATTENTION : ["action", "problem"].includes(line.level) ? ERROR_TEXT : undefined,
|
|
23174
|
+
}, line.text)),
|
|
22170
23175
|
!showHelp &&
|
|
23176
|
+
!notificationsOpen &&
|
|
22171
23177
|
tooNarrow &&
|
|
22172
23178
|
e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
|
|
22173
|
-
...(showHelp || !tableVisible ? [] : noticeLines).map((line, index) =>
|
|
23179
|
+
...(showHelp || notificationsOpen || !tableVisible ? [] : noticeLines).map((line, index) =>
|
|
22174
23180
|
e(Text, { key: `recovery:${index}`, color: noticeTone, wrap: "truncate-end" }, line)),
|
|
22175
23181
|
!showHelp &&
|
|
22176
|
-
!
|
|
22177
|
-
!remoteSetup &&
|
|
22178
|
-
tableVisible &&
|
|
22179
|
-
tab.key === "security" &&
|
|
22180
|
-
visibleSecurityLines.map((note, i) =>
|
|
22181
|
-
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
22182
|
-
),
|
|
22183
|
-
!showHelp &&
|
|
23182
|
+
!notificationsOpen &&
|
|
22184
23183
|
!tooNarrow &&
|
|
22185
23184
|
remoteSetup &&
|
|
22186
23185
|
(interactive ? REMOTE_SETUP_LINES : REMOTE_SETUP_NONINTERACTIVE_LINES).map(
|
|
@@ -22198,6 +23197,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22198
23197
|
),
|
|
22199
23198
|
),
|
|
22200
23199
|
!showHelp &&
|
|
23200
|
+
!notificationsOpen &&
|
|
22201
23201
|
!tooNarrow &&
|
|
22202
23202
|
!remoteSetup &&
|
|
22203
23203
|
tableVisible &&
|
|
@@ -22206,7 +23206,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22206
23206
|
selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
|
|
22207
23207
|
headerRef,
|
|
22208
23208
|
}),
|
|
22209
|
-
...(tooNarrow || showHelp || remoteSetup || ciView || attentionView ? [] : visibleItems).map((item) => {
|
|
23209
|
+
...(tooNarrow || showHelp || notificationsOpen || remoteSetup || ciView || attentionView ? [] : visibleItems).map((item) => {
|
|
22210
23210
|
const key = itemKey(item);
|
|
22211
23211
|
return e(
|
|
22212
23212
|
RowBoundary,
|
|
@@ -22227,7 +23227,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22227
23227
|
}),
|
|
22228
23228
|
);
|
|
22229
23229
|
}),
|
|
22230
|
-
!showHelp && !remoteSetup && !tableVisible &&
|
|
23230
|
+
!showHelp && !notificationsOpen && !remoteSetup && !tableVisible &&
|
|
22231
23231
|
panelLines.map((line, index) => e(Text, {
|
|
22232
23232
|
key: `context:${index}`, wrap: "truncate-end",
|
|
22233
23233
|
bold: index === 0, color: index === 0 ? TITLE_COLOR : undefined,
|
|
@@ -22247,17 +23247,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
22247
23247
|
nowMs: now.getTime(),
|
|
22248
23248
|
interactive,
|
|
22249
23249
|
cols: frameCols,
|
|
22250
|
-
remoteSetup,
|
|
22251
|
-
feature: attentionView ?? ciView ?? focus,
|
|
23250
|
+
remoteSetup: remoteSetup && !notificationsOpen,
|
|
23251
|
+
feature: notificationsOpen ? null : attentionView ?? ciView ?? focus,
|
|
22252
23252
|
widthMode: resizeRef.current.active,
|
|
22253
23253
|
widthColumn: selectedWidthColumn,
|
|
22254
23254
|
widthSaveError,
|
|
22255
|
-
canMove: allItems.length > 0,
|
|
23255
|
+
canMove: notificationsOpen ? (notificationView?.maxOffset ?? 0) > 0 : allItems.length > 0,
|
|
22256
23256
|
canOpen:
|
|
23257
|
+
!notificationsOpen &&
|
|
22257
23258
|
selectedKey !== null &&
|
|
22258
23259
|
OPENABLE.includes(tab.key) &&
|
|
22259
23260
|
allItems.some((item) => itemKey(item) === selectedKey),
|
|
22260
|
-
canResize: fullHeaderVisible,
|
|
23261
|
+
canResize: !notificationsOpen && fullHeaderVisible,
|
|
22261
23262
|
}),
|
|
22262
23263
|
);
|
|
22263
23264
|
}
|
|
@@ -22637,12 +23638,14 @@ export {
|
|
|
22637
23638
|
maintainControlLease,
|
|
22638
23639
|
claimProbe,
|
|
22639
23640
|
classifyThrottle,
|
|
23641
|
+
secondaryThrottleEvidence,
|
|
22640
23642
|
applyTransportCooldown,
|
|
22641
23643
|
clearTransportThrottle,
|
|
22642
23644
|
emptyTransportThrottle,
|
|
22643
23645
|
throttleLadderMs,
|
|
22644
23646
|
transportCooldownDeadline,
|
|
22645
23647
|
retryThrottledTransport,
|
|
23648
|
+
retryPausedTransport,
|
|
22646
23649
|
THROTTLE_LADDER_MS,
|
|
22647
23650
|
THROTTLE_PAUSE_AFTER,
|
|
22648
23651
|
MANUAL_GRANT_STREAK_LIMIT,
|
|
@@ -22806,6 +23809,7 @@ export {
|
|
|
22806
23809
|
saveDashboardCache,
|
|
22807
23810
|
mergeDashboardCacheSnapshots,
|
|
22808
23811
|
nextSecurityRaw,
|
|
23812
|
+
adoptedAcquisitionRaw,
|
|
22809
23813
|
shouldCheckpointFreshness,
|
|
22810
23814
|
ACQUISITION_STORE_VERSION,
|
|
22811
23815
|
ACQUISITION_CLAIM_TTL_MS,
|
|
@@ -22876,6 +23880,25 @@ export {
|
|
|
22876
23880
|
collectorSourceAdvanced,
|
|
22877
23881
|
cleanupQueueForResource,
|
|
22878
23882
|
presentRecovery,
|
|
23883
|
+
RECOVERY_COPY,
|
|
23884
|
+
NOTICE_LEVELS,
|
|
23885
|
+
NOTIFICATION_LOG_MAX,
|
|
23886
|
+
noticeLevel,
|
|
23887
|
+
fetchErrorLevel,
|
|
23888
|
+
createNotificationLog,
|
|
23889
|
+
notificationConditions,
|
|
23890
|
+
diffNotificationSnapshots,
|
|
23891
|
+
seedNotificationsFromJournal,
|
|
23892
|
+
criticalInlineLines,
|
|
23893
|
+
notificationViewLines,
|
|
23894
|
+
tabBarEntries,
|
|
23895
|
+
tabBarWidth,
|
|
23896
|
+
GITHUB_STATUS_URL,
|
|
23897
|
+
GITHUB_STATUS_MAX_BYTES,
|
|
23898
|
+
githubStatusConfig,
|
|
23899
|
+
fetchGitHubStatus,
|
|
23900
|
+
githubStatusLine,
|
|
23901
|
+
createGitHubStatusMonitor,
|
|
22879
23902
|
dashboardPresentation,
|
|
22880
23903
|
contextualPanelLayout,
|
|
22881
23904
|
dashboardTarget,
|