@nbtca/prompt 1.4.2 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +167 -64
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +16 -23
- package/dist/app/keys.js +110 -2
- package/dist/app/views/docs-render.js +34 -26
- package/dist/app/views/docs.js +280 -68
- package/dist/app/views/events-render.js +21 -27
- package/dist/app/views/events.js +57 -33
- package/dist/app/views/home.js +67 -47
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +51 -74
- package/dist/app/views/schedule.js +246 -101
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/cli.js +570 -0
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +21 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/canvas.js +1 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +64 -38
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +34 -7
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +186 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +156 -19
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar-store.js +27 -0
- package/dist/features/calendar.js +66 -190
- package/dist/features/docs-client.js +225 -0
- package/dist/features/docs.js +615 -298
- package/dist/features/links.js +44 -29
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +117 -60
- package/dist/features/student-timetable.js +74 -97
- package/dist/features/theme.js +9 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +12 -29
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +8 -3
- package/dist/i18n/locales/zh.json +8 -3
- package/dist/index.js +6 -474
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +28 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- package/dist/logo/ca-logo.png +0 -0
|
@@ -1,212 +1,57 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { withAuthenticatedSession, resolveTerm, relevantTerms, writePrivateIcs, isSessionExpired, safeMessage, JWXT_ORIGIN, } from './student-timetable.js';
|
|
11
|
-
import { currentWeekNumber, campusWeekday, meetingsOnDay, nextMeeting } from './schedule-query.js';
|
|
12
|
-
import { renderNextClassBanner, renderTodayClasses, renderTodayTimeline, renderWeekGrid } from './schedule-render.js';
|
|
13
|
-
import { termKey, loadWeekOne, saveWeekOne, saveTimetableCache, saveCurrentPointer, loadCurrentPointer, loadTimetableCache, clearScheduleCache, } from './schedule-store.js';
|
|
14
|
-
/** Loads a saved week-one Monday for `key`, or prompts for and persists a new one.
|
|
15
|
-
* Returns null when the user cancels or enters an unparsable date (caller aborts). */
|
|
16
|
-
async function ensureWeekOne(key) {
|
|
17
|
-
const saved = loadWeekOne(key);
|
|
18
|
-
if (saved)
|
|
19
|
-
return saved;
|
|
20
|
-
const value = await runTextInput({
|
|
21
|
-
message: t().timetable.promptWeekOne,
|
|
22
|
-
placeholder: 'YYYY-MM-DD',
|
|
23
|
-
});
|
|
24
|
-
if (value === null)
|
|
1
|
+
import { createTimetableSchedule } from '@nbtca/nbtcal/timetable';
|
|
2
|
+
import { addLocalDays, parseLocalMonday } from '../core/calendar-day.js';
|
|
3
|
+
import { renderNextClassBanner, renderTodayTimeline } from './schedule-render.js';
|
|
4
|
+
import { loadCurrentPointer, loadTimetableCache } from './schedule-store.js';
|
|
5
|
+
import { sanitizeTimetable } from './timetable-sanitize.js';
|
|
6
|
+
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
|
|
7
|
+
function loadCachedTimetable() {
|
|
8
|
+
const pointer = loadCurrentPointer();
|
|
9
|
+
if (!pointer)
|
|
25
10
|
return null;
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
11
|
+
const value = loadTimetableCache(pointer.termKey);
|
|
12
|
+
if (!value || typeof value !== 'object')
|
|
28
13
|
return null;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* (after reporting the error) on failure so callers can keep the previous state. */
|
|
36
|
-
async function fetchTimetableWithSpinner(client, term, key, weekOne) {
|
|
37
|
-
const trans = t();
|
|
38
|
-
const spinner = createSpinner(trans.calendar.loading);
|
|
39
|
-
try {
|
|
40
|
-
const tt = await client.fetchTerm(term);
|
|
41
|
-
spinner.stop();
|
|
42
|
-
saveTimetableCache(key, tt);
|
|
43
|
-
saveCurrentPointer(key, weekOne);
|
|
44
|
-
return tt;
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
if (isSessionExpired(err)) {
|
|
48
|
-
spinner.stop();
|
|
49
|
-
throw err;
|
|
50
|
-
}
|
|
51
|
-
spinner.error(trans.timetable.genericError);
|
|
14
|
+
const candidate = value;
|
|
15
|
+
if (!Array.isArray(candidate.meetings) ||
|
|
16
|
+
!Array.isArray(candidate.periods) ||
|
|
17
|
+
!Array.isArray(candidate.unresolvedItems) ||
|
|
18
|
+
!Array.isArray(candidate.calendarDays) ||
|
|
19
|
+
!Array.isArray(candidate.warnings)) {
|
|
52
20
|
return null;
|
|
53
21
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const week = currentWeekNumber(weekOne, now);
|
|
57
|
-
const today = meetingsOnDay(tt.meetings, campusWeekday(now), week);
|
|
58
|
-
const trans = t();
|
|
59
|
-
console.log();
|
|
60
|
-
const banner = renderNextClassBanner(nextMeeting(tt.meetings, tt.periods, weekOne, now), now);
|
|
61
|
-
console.log(banner || `${space.indent}${type.hint(trans.timetable.noNextClass)}`);
|
|
62
|
-
console.log();
|
|
63
|
-
console.log(renderTodayClasses(today, tt.periods, now));
|
|
64
|
-
console.log();
|
|
65
|
-
return { week, today };
|
|
66
|
-
}
|
|
67
|
-
async function switchTerm(client, catalog) {
|
|
68
|
-
const trans = t();
|
|
69
|
-
const picked = await runMenu({
|
|
70
|
-
title: trans.timetable.hubSwitchTerm,
|
|
71
|
-
options: relevantTerms(catalog).map((tm) => ({
|
|
72
|
-
value: `${tm.academicYear}:${tm.semester}`,
|
|
73
|
-
label: tm.academicYearLabel,
|
|
74
|
-
hint: tm.current ? trans.common.current : undefined,
|
|
75
|
-
})),
|
|
76
|
-
footer: menuFooter(),
|
|
77
|
-
});
|
|
78
|
-
if (picked === null)
|
|
22
|
+
const fetchedAt = new Date(candidate.fetchedAt);
|
|
23
|
+
if (Number.isNaN(fetchedAt.getTime()))
|
|
79
24
|
return null;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return null;
|
|
85
|
-
const tt = await fetchTimetableWithSpinner(client, term, key, weekOne);
|
|
86
|
-
if (!tt)
|
|
87
|
-
return null;
|
|
88
|
-
return { term, key, weekOne, tt };
|
|
89
|
-
}
|
|
90
|
-
function exportTimetable(tt, term, key, weekOne) {
|
|
91
|
-
const trans = t();
|
|
92
|
-
const ics = timetableToIcs(tt, { weekOneMonday: weekOne, calendarName: `NBT ${term.academicYearLabel}` });
|
|
93
|
-
const out = `timetable-${key}.ics`;
|
|
94
|
-
try {
|
|
95
|
-
writePrivateIcs(out, ics);
|
|
96
|
-
success(fmt(trans.timetable.exported, { count: tt.meetings.length, file: path.resolve(out) }));
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
error(trans.timetable.genericError);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
/** Interactive schedule hub: login/restore, resolve the current term, load or prompt
|
|
103
|
-
* for the week-one Monday, fetch the timetable, then loop a menu of today / week-grid /
|
|
104
|
-
* switch-term / export actions until the user cancels. */
|
|
105
|
-
export async function showSchedule() {
|
|
106
|
-
const trans = t();
|
|
107
|
-
await enterScreen(breadcrumb(trans.timetable.menuEntry));
|
|
108
|
-
try {
|
|
109
|
-
await withAuthenticatedSession(async (session) => {
|
|
110
|
-
const client = createNbtTimetableClient(session.timetableTransport, { baseUrl: JWXT_ORIGIN });
|
|
111
|
-
const catalog = await client.listTerms();
|
|
112
|
-
let term = resolveTerm(catalog);
|
|
113
|
-
let key = termKey(term);
|
|
114
|
-
let weekOne = await ensureWeekOne(key);
|
|
115
|
-
if (!weekOne)
|
|
116
|
-
return 0;
|
|
117
|
-
const initial = await fetchTimetableWithSpinner(client, term, key, weekOne);
|
|
118
|
-
if (!initial)
|
|
119
|
-
return 1;
|
|
120
|
-
let tt = initial;
|
|
121
|
-
while (true) {
|
|
122
|
-
const now = new Date();
|
|
123
|
-
const { week, today } = renderHub(tt, weekOne, now);
|
|
124
|
-
const action = await runMenu({
|
|
125
|
-
title: `${trans.timetable.menuEntry} ${c.muted(term.academicYearLabel)} ${c.muted(trans.timetable.weekLabel + String(week))}`,
|
|
126
|
-
options: [
|
|
127
|
-
{ value: 'today', label: trans.timetable.hubToday, hint: String(today.length) },
|
|
128
|
-
{ value: 'week', label: trans.timetable.hubWeek },
|
|
129
|
-
{ value: 'term', label: trans.timetable.hubSwitchTerm, hint: term.academicYearLabel },
|
|
130
|
-
{ value: 'export', label: trans.timetable.hubExport },
|
|
131
|
-
{ value: 'logout', label: trans.timetable.hubLogout },
|
|
132
|
-
],
|
|
133
|
-
footer: menuFooter(),
|
|
134
|
-
});
|
|
135
|
-
if (action === null)
|
|
136
|
-
return 0;
|
|
137
|
-
if (action === 'today') {
|
|
138
|
-
continue; // The next loop iteration repaints today's classes.
|
|
139
|
-
}
|
|
140
|
-
if (action === 'week') {
|
|
141
|
-
console.log();
|
|
142
|
-
console.log(renderWeekGrid(tt.meetings, tt.periods, week, now));
|
|
143
|
-
console.log();
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (action === 'term') {
|
|
147
|
-
const switched = await switchTerm(client, catalog);
|
|
148
|
-
if (!switched)
|
|
149
|
-
continue;
|
|
150
|
-
term = switched.term;
|
|
151
|
-
key = switched.key;
|
|
152
|
-
weekOne = switched.weekOne;
|
|
153
|
-
tt = switched.tt;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (action === 'export') {
|
|
157
|
-
exportTimetable(tt, term, key, weekOne);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (action === 'logout') {
|
|
161
|
-
createSessionStore().clear();
|
|
162
|
-
clearScheduleCache();
|
|
163
|
-
return 0;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}, {
|
|
167
|
-
oneShot: false,
|
|
168
|
-
isInteractive: true,
|
|
169
|
-
store: createSessionStore(),
|
|
170
|
-
stderr: process.stderr,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
error(safeMessage(err));
|
|
175
|
-
}
|
|
25
|
+
return {
|
|
26
|
+
timetable: sanitizeTimetable({ ...candidate, fetchedAt }),
|
|
27
|
+
weekOneMonday: pointer.weekOneMonday,
|
|
28
|
+
};
|
|
176
29
|
}
|
|
177
|
-
/** Best-effort, cache-only startup line: reads the last-used term pointer and its
|
|
178
|
-
* cached timetable (no network) and renders the next-class banner, or '' if the
|
|
179
|
-
* student isn't "set up" yet or anything about the cache is missing/corrupt. */
|
|
180
30
|
export function peekNextClassLine(now = new Date()) {
|
|
181
31
|
try {
|
|
182
|
-
const
|
|
183
|
-
if (!
|
|
32
|
+
const cached = loadCachedTimetable();
|
|
33
|
+
if (!cached)
|
|
184
34
|
return '';
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return renderNextClassBanner(next, now);
|
|
35
|
+
const schedule = createTimetableSchedule(cached.timetable, {
|
|
36
|
+
weekOneMonday: cached.weekOneMonday,
|
|
37
|
+
});
|
|
38
|
+
return renderNextClassBanner(schedule.next(now), now);
|
|
190
39
|
}
|
|
191
40
|
catch {
|
|
192
41
|
return '';
|
|
193
42
|
}
|
|
194
43
|
}
|
|
195
|
-
/** Cache-only (no network) render of today's classes for the current term, or
|
|
196
|
-
* [] if not set up. Uses the same renderTodayTimeline Schedule's own hub
|
|
197
|
-
* renders, so Home's preview and Schedule's detail view show the exact same
|
|
198
|
-
* visual language for the exact same data instead of drifting apart. */
|
|
199
44
|
export function peekTodayLines(now = new Date()) {
|
|
200
45
|
try {
|
|
201
|
-
const
|
|
202
|
-
if (!
|
|
203
|
-
return [];
|
|
204
|
-
const cached = loadTimetableCache(ptr.termKey);
|
|
205
|
-
if (!cached || !Array.isArray(cached.meetings) || !Array.isArray(cached.periods))
|
|
46
|
+
const cached = loadCachedTimetable();
|
|
47
|
+
if (!cached)
|
|
206
48
|
return [];
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
49
|
+
const schedule = createTimetableSchedule(cached.timetable, {
|
|
50
|
+
weekOneMonday: cached.weekOneMonday,
|
|
51
|
+
});
|
|
52
|
+
const week = schedule.weekAt(now);
|
|
53
|
+
const today = schedule.meetingsOnDay(week, schedule.weekdayAt(now));
|
|
54
|
+
return renderTodayTimeline(today, cached.timetable.periods, now).split('\n');
|
|
210
55
|
}
|
|
211
56
|
catch {
|
|
212
57
|
return [];
|
|
@@ -214,38 +59,26 @@ export function peekTodayLines(now = new Date()) {
|
|
|
214
59
|
}
|
|
215
60
|
export function peekWeekAheadInfo(now = new Date()) {
|
|
216
61
|
try {
|
|
217
|
-
const
|
|
218
|
-
if (!
|
|
219
|
-
return null;
|
|
220
|
-
const cached = loadTimetableCache(ptr.termKey);
|
|
221
|
-
if (!cached || !Array.isArray(cached.meetings))
|
|
62
|
+
const cached = loadCachedTimetable();
|
|
63
|
+
if (!cached)
|
|
222
64
|
return null;
|
|
223
|
-
const
|
|
65
|
+
const schedule = createTimetableSchedule(cached.timetable, {
|
|
66
|
+
weekOneMonday: cached.weekOneMonday,
|
|
67
|
+
});
|
|
68
|
+
const week = schedule.weekAt(now);
|
|
224
69
|
if (week < 1)
|
|
225
70
|
return null;
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
const base = new Date(`${ptr.weekOneMonday}T00:00:00`);
|
|
229
|
-
const weekStartDate = new Date(base.getTime() + (week - 1) * 7 * 86400000);
|
|
71
|
+
const classDays = WEEKDAYS.map((weekday) => schedule.meetingsOnDay(week, weekday).length > 0);
|
|
72
|
+
const weekStartDate = addLocalDays(parseLocalMonday(cached.weekOneMonday), (week - 1) * 7);
|
|
230
73
|
return { weekStartDate, classDays };
|
|
231
74
|
}
|
|
232
75
|
catch {
|
|
233
76
|
return null;
|
|
234
77
|
}
|
|
235
78
|
}
|
|
236
|
-
/** Best-effort, cache-only (no network) read of how many timetable items
|
|
237
|
-
* still need the student's attention — the same data buildHubField()
|
|
238
|
-
* (schedule.ts) already surfaces inside Schedule's own hub menu, exposed
|
|
239
|
-
* here so Home can show it too without a second source of truth. */
|
|
240
79
|
export function peekUnresolvedCount() {
|
|
241
80
|
try {
|
|
242
|
-
|
|
243
|
-
if (!ptr)
|
|
244
|
-
return 0;
|
|
245
|
-
const cached = loadTimetableCache(ptr.termKey);
|
|
246
|
-
if (!cached || !Array.isArray(cached.unresolvedItems))
|
|
247
|
-
return 0;
|
|
248
|
-
return cached.unresolvedItems.length;
|
|
81
|
+
return loadCachedTimetable()?.timetable.unresolvedItems.length ?? 0;
|
|
249
82
|
}
|
|
250
83
|
catch {
|
|
251
84
|
return 0;
|
package/dist/features/status.js
CHANGED
|
@@ -1,13 +1,69 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { APP_INFO, URLS } from '../config/data.js';
|
|
3
|
-
import { getCapabilities } from '../core/capabilities.js';
|
|
4
|
-
import { createPainter } from '../core/components/painter.js';
|
|
5
3
|
import { pickIcon } from '../core/icons.js';
|
|
6
|
-
import { padEndV, visualWidth } from '../core/text.js';
|
|
4
|
+
import { padEndV, sanitizeTerminalLine, visualWidth } from '../core/text.js';
|
|
7
5
|
import { c } from '../core/theme.js';
|
|
8
|
-
import { createSpinner } from '../core/ui.js';
|
|
9
|
-
import { enterScreen, breadcrumb } from '../core/transitions.js';
|
|
10
6
|
import { t } from '../i18n/index.js';
|
|
7
|
+
function abortError(signal) {
|
|
8
|
+
return signal.reason instanceof Error
|
|
9
|
+
? signal.reason
|
|
10
|
+
: new DOMException('The status check was aborted.', 'AbortError');
|
|
11
|
+
}
|
|
12
|
+
function cancelResponseBody(response) {
|
|
13
|
+
try {
|
|
14
|
+
const body = response?.body;
|
|
15
|
+
if (!body)
|
|
16
|
+
return;
|
|
17
|
+
void body.cancel().catch(() => {
|
|
18
|
+
// Cancellation is best-effort and must not delay status results.
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// A custom transport may expose an inaccessible, locked, or closed body.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function raceWithSignal(operation, signal, onLateValue) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
let settled = false;
|
|
28
|
+
const cleanup = () => {
|
|
29
|
+
signal.removeEventListener('abort', onAbort);
|
|
30
|
+
};
|
|
31
|
+
const onAbort = () => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
cleanup();
|
|
36
|
+
reject(abortError(signal));
|
|
37
|
+
};
|
|
38
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
39
|
+
void operation
|
|
40
|
+
.then((value) => {
|
|
41
|
+
if (settled) {
|
|
42
|
+
try {
|
|
43
|
+
onLateValue?.(value);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Late cleanup is best-effort.
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
settled = true;
|
|
51
|
+
cleanup();
|
|
52
|
+
resolve(value);
|
|
53
|
+
}, (error) => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
cleanup();
|
|
58
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {
|
|
61
|
+
// Both operation outcomes are handled above. This only guards cleanup code.
|
|
62
|
+
});
|
|
63
|
+
if (signal.aborted)
|
|
64
|
+
onAbort();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
11
67
|
function getServiceTargets() {
|
|
12
68
|
const trans = t();
|
|
13
69
|
return [
|
|
@@ -21,46 +77,73 @@ function getServiceTargets() {
|
|
|
21
77
|
{ name: trans.status.serviceMirror, url: URLS.mirror, group: 'intranet', intranet: true },
|
|
22
78
|
];
|
|
23
79
|
}
|
|
24
|
-
async function checkService(name, url, timeoutMs) {
|
|
80
|
+
async function checkService(name, url, timeoutMs, signal) {
|
|
25
81
|
const start = Date.now();
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const onAbort = () => {
|
|
84
|
+
controller.abort(signal?.reason);
|
|
85
|
+
};
|
|
86
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
87
|
+
if (signal?.aborted)
|
|
88
|
+
onAbort();
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
controller.abort();
|
|
91
|
+
}, timeoutMs);
|
|
92
|
+
let response;
|
|
26
93
|
try {
|
|
27
|
-
|
|
28
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
-
const response = await fetch(url, {
|
|
94
|
+
response = await raceWithSignal(fetch(url, {
|
|
30
95
|
signal: controller.signal,
|
|
31
96
|
redirect: 'follow',
|
|
32
97
|
headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
|
|
33
|
-
});
|
|
34
|
-
|
|
98
|
+
}), controller.signal, cancelResponseBody);
|
|
99
|
+
if (signal?.aborted)
|
|
100
|
+
throw abortError(signal);
|
|
35
101
|
const latencyMs = Date.now() - start;
|
|
36
102
|
const ok = response.status >= 200 && response.status < 400;
|
|
37
103
|
return { name, url, ok, statusCode: response.status, latencyMs };
|
|
38
104
|
}
|
|
39
105
|
catch (err) {
|
|
106
|
+
if (signal?.aborted)
|
|
107
|
+
throw abortError(signal);
|
|
40
108
|
const latencyMs = Date.now() - start;
|
|
41
|
-
const error = err instanceof Error
|
|
42
|
-
?
|
|
43
|
-
|
|
109
|
+
const error = sanitizeTerminalLine(err instanceof Error
|
|
110
|
+
? err.name === 'AbortError'
|
|
111
|
+
? 'Request timed out'
|
|
112
|
+
: err.message
|
|
113
|
+
: String(err));
|
|
44
114
|
return { name, url, ok: false, latencyMs, error };
|
|
45
115
|
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
signal?.removeEventListener('abort', onAbort);
|
|
119
|
+
cancelResponseBody(response);
|
|
120
|
+
}
|
|
46
121
|
}
|
|
47
|
-
|
|
48
|
-
let lastResult = await checkService(target.name, target.url, timeoutMs);
|
|
122
|
+
async function checkServiceWithRetry(target, timeoutMs, retries, signal) {
|
|
123
|
+
let lastResult = await checkService(target.name, target.url, timeoutMs, signal);
|
|
49
124
|
if (!lastResult.ok) {
|
|
50
125
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
51
126
|
if (!lastResult.error && !(lastResult.statusCode != null && lastResult.statusCode >= 500))
|
|
52
127
|
break;
|
|
53
|
-
|
|
128
|
+
if (signal?.aborted)
|
|
129
|
+
throw abortError(signal);
|
|
130
|
+
lastResult = await checkService(target.name, target.url, timeoutMs, signal);
|
|
54
131
|
if (lastResult.ok)
|
|
55
132
|
break;
|
|
56
133
|
}
|
|
57
134
|
}
|
|
58
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
...lastResult,
|
|
137
|
+
group: target.group,
|
|
138
|
+
...(target.intranet === undefined ? {} : { intranet: target.intranet }),
|
|
139
|
+
};
|
|
59
140
|
}
|
|
60
141
|
export async function checkServices(options = {}) {
|
|
61
142
|
const timeoutMs = options.timeoutMs ?? 6000;
|
|
62
143
|
const retries = options.retries ?? 1;
|
|
63
|
-
|
|
144
|
+
if (options.signal?.aborted)
|
|
145
|
+
throw abortError(options.signal);
|
|
146
|
+
return Promise.all(getServiceTargets().map((target) => checkServiceWithRetry(target, timeoutMs, retries, options.signal)));
|
|
64
147
|
}
|
|
65
148
|
export function serializeServiceStatus(items) {
|
|
66
149
|
return items.map((item) => ({
|
|
@@ -75,12 +158,12 @@ export function serializeServiceStatus(items) {
|
|
|
75
158
|
}));
|
|
76
159
|
}
|
|
77
160
|
export function hasServiceFailures(items) {
|
|
78
|
-
return items.some(item => !item.ok && !item.intranet);
|
|
161
|
+
return items.some((item) => !item.ok && !item.intranet);
|
|
79
162
|
}
|
|
80
163
|
export function countServiceHealth(items) {
|
|
81
164
|
let up = 0;
|
|
82
165
|
let down = 0;
|
|
83
|
-
for (const item of items.filter(i => !i.intranet)) {
|
|
166
|
+
for (const item of items.filter((i) => !i.intranet)) {
|
|
84
167
|
if (item.ok)
|
|
85
168
|
up++;
|
|
86
169
|
else
|
|
@@ -97,7 +180,7 @@ export function renderServiceStatusTable(items, options) {
|
|
|
97
180
|
const applyCyan = useColor ? chalk.cyan : id;
|
|
98
181
|
const applyLatency = useColor ? c.latency : (ms) => `${ms}ms`;
|
|
99
182
|
const trans = t();
|
|
100
|
-
const nameWidth = Math.max(...items.map(i => visualWidth(i.name)), visualWidth(trans.status.service));
|
|
183
|
+
const nameWidth = Math.max(...items.map((i) => visualWidth(i.name)), visualWidth(trans.status.service));
|
|
101
184
|
const statusWidth = 10;
|
|
102
185
|
const onIcon = pickIcon('●', '+');
|
|
103
186
|
const offIcon = pickIcon('✕', '!');
|
|
@@ -109,14 +192,22 @@ export function renderServiceStatusTable(items, options) {
|
|
|
109
192
|
if (item.group !== currentGroup) {
|
|
110
193
|
if (currentGroup !== undefined)
|
|
111
194
|
lines.push('');
|
|
112
|
-
const groupLabel = item.group === 'nbtca'
|
|
113
|
-
|
|
114
|
-
|
|
195
|
+
const groupLabel = item.group === 'nbtca'
|
|
196
|
+
? trans.status.groupNbtca
|
|
197
|
+
: item.group === 'external'
|
|
198
|
+
? trans.status.groupExternal
|
|
199
|
+
: item.group === 'intranet'
|
|
200
|
+
? trans.status.groupIntranet
|
|
201
|
+
: '';
|
|
115
202
|
lines.push(` ${applyDim(groupLabel)}`);
|
|
116
203
|
lines.push(` ${applyDim(sep.repeat(nameWidth + statusWidth + 12))}`);
|
|
117
204
|
currentGroup = item.group;
|
|
118
205
|
}
|
|
119
|
-
const nameCol = padEndV(item.pending
|
|
206
|
+
const nameCol = padEndV(item.pending
|
|
207
|
+
? applyDim(item.name)
|
|
208
|
+
: item.intranet
|
|
209
|
+
? applyDim(item.name)
|
|
210
|
+
: applyCyan(item.name), nameWidth);
|
|
120
211
|
let statusLabel;
|
|
121
212
|
if (item.pending) {
|
|
122
213
|
statusLabel = applyDim(pickIcon('…', '.'));
|
|
@@ -138,37 +229,3 @@ export function renderServiceStatusTable(items, options) {
|
|
|
138
229
|
}
|
|
139
230
|
return lines.join('\n');
|
|
140
231
|
}
|
|
141
|
-
export async function showServiceStatus() {
|
|
142
|
-
const trans = t();
|
|
143
|
-
await enterScreen(breadcrumb(trans.menu.status));
|
|
144
|
-
const targets = getServiceTargets();
|
|
145
|
-
if (getCapabilities().reducedMotion) {
|
|
146
|
-
const spinner = createSpinner(trans.status.checking);
|
|
147
|
-
const items = await checkServices();
|
|
148
|
-
const hasFailures = hasServiceFailures(items);
|
|
149
|
-
if (hasFailures)
|
|
150
|
-
spinner.error(trans.status.summaryFail);
|
|
151
|
-
else
|
|
152
|
-
spinner.stop(trans.status.summaryOk);
|
|
153
|
-
console.log();
|
|
154
|
-
console.log(renderServiceStatusTable(items, { color: !!process.stdout.isTTY }));
|
|
155
|
-
console.log();
|
|
156
|
-
return items;
|
|
157
|
-
}
|
|
158
|
-
const items = targets.map((tg) => ({
|
|
159
|
-
name: tg.name, url: tg.url, ok: false, group: tg.group, intranet: tg.intranet, pending: true,
|
|
160
|
-
}));
|
|
161
|
-
const paint = createPainter(() => renderServiceStatusTable(items, { color: getCapabilities().color }));
|
|
162
|
-
console.log();
|
|
163
|
-
paint();
|
|
164
|
-
await Promise.all(targets.map(async (target, i) => {
|
|
165
|
-
const status = await checkServiceWithRetry(target, 6000, 1);
|
|
166
|
-
items[i] = { ...status, pending: false };
|
|
167
|
-
paint();
|
|
168
|
-
}));
|
|
169
|
-
console.log('\n');
|
|
170
|
-
const hasFailures = hasServiceFailures(items);
|
|
171
|
-
console.log(hasFailures ? c.warn(trans.status.summaryFail) : c.success(trans.status.summaryOk));
|
|
172
|
-
console.log();
|
|
173
|
-
return items;
|
|
174
|
-
}
|