@ninhache/probe 0.9.0 → 0.10.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/README.md CHANGED
@@ -113,13 +113,14 @@ of macOS.
113
113
  | --------------- | ---------------------------------------------- |
114
114
  | `↑` `↓` `j` `k` | Move |
115
115
  | `r` | Check now |
116
- | `Enter` | Open the panel of the selected service |
116
+ | `Enter` | Open the detail of the selected service |
117
117
  | `u` | Open its URL in the browser |
118
118
  | `c` | Copy its curl command |
119
119
  | `/` | Filter by name; `Enter` keeps it, `Esc` clears |
120
120
  | `a` | Add a service |
121
121
  | `e` | Edit the selected service |
122
122
  | `d` | Remove the selected service, after a question |
123
+ | `:` | Find a command by what it does; `Enter` runs |
123
124
  | `o` | Options: interval and timeout |
124
125
  | `?` | Every key; `?` or `Esc` closes |
125
126
  | `q` | Quit |
@@ -132,7 +133,12 @@ to copy by hand.
132
133
  `a` and `e` open a form: name, port, host, path, expected status. `↑` `↓` or `Tab` change field, `Enter` saves,
133
134
  `Esc` cancels. A field left empty takes its default. `d` asks first: only `y` removes. All three write
134
135
  `.probe/config.yml` the way `probe add` and `probe rm` do: comments kept, [Makefile](#makefile) block kept up to
135
- date. While you type, in the filter or in the form, `q` and `?` are letters.
136
+ date. While you type, in the filter, the form or the command palette, `q` and `?` are letters.
137
+
138
+ `Enter` shows the last answer of the selected service above its two settings: status, latencies of this
139
+ session, headers and the first lines of the body; for a service that is down, what failed and what to try. The
140
+ value of a header that can carry a secret (`Authorization`, `Set-Cookie`, anything named after a token or a key)
141
+ is replaced by `(hidden)`. None of it is written anywhere, and `probe check --json` prints what it printed before.
136
142
 
137
143
  The panels save what you change in `.probe/local.yml`, for your machine only:
138
144
 
@@ -14,6 +14,9 @@ var DEFAULT_MAKEFILE = "Makefile";
14
14
  var DEFAULT_CHECKER_TYPE = "http";
15
15
  var DEFAULT_TIMEOUT_SECONDS = 3;
16
16
  var DASHBOARD_INTERVAL_SECONDS = 2;
17
+ var DASHBOARD_HISTORY_LENGTH = 30;
18
+ var RESPONSE_BODY_BYTES = 2048;
19
+ var RESPONSE_BODY_WAIT_MS = 500;
17
20
  var DASHBOARD_INTERVAL_CHOICES_SECONDS = [0.5, 1, 2, 5, 10, 30, 60];
18
21
  var DASHBOARD_TIMEOUT_CHOICES_SECONDS = [1, 2, 3, 5, 10, 30];
19
22
  var MAX_TIMEOUT_SECONDS = 2147483;
@@ -162,6 +165,79 @@ function diagnoseStatus(service, status) {
162
165
  return { error, hint: `the service answers: check \`path\` and \`expect\` in ${CONFIG}` };
163
166
  }
164
167
 
168
+ // src/checkers/response.ts
169
+ var HIDDEN_VALUE = "(hidden)";
170
+ var SENSITIVE_HEADER = /auth|cookie|token|secret|key|session|password|credential/i;
171
+ var TEXT_TYPE = /^text\/|json|xml|javascript|yaml|x-www-form-urlencoded/i;
172
+ var NUL = 0;
173
+ var FIRST_PRINTABLE = 32;
174
+ var DELETE = 127;
175
+ var LAST_C1_CONTROL = 159;
176
+ var REPLACEMENT = "\uFFFD";
177
+ function isControl(character) {
178
+ const code = character.codePointAt(0) ?? NUL;
179
+ return code < FIRST_PRINTABLE || code >= DELETE && code <= LAST_C1_CONTROL;
180
+ }
181
+ function printable(text, kept = "") {
182
+ return Array.from(text).filter((character) => kept.includes(character) || !isControl(character)).join("");
183
+ }
184
+ async function readStart(body) {
185
+ const reader = body.getReader();
186
+ const chunks = [];
187
+ let size = 0;
188
+ let ended = false;
189
+ let timer;
190
+ const late = new Promise((resolve) => {
191
+ timer = setTimeout(() => resolve("late"), RESPONSE_BODY_WAIT_MS);
192
+ });
193
+ try {
194
+ while (size < RESPONSE_BODY_BYTES) {
195
+ const read = reader.read();
196
+ read.catch(() => {
197
+ });
198
+ const next = await Promise.race([read, late]);
199
+ if (next === "late") break;
200
+ if (next.done) {
201
+ ended = true;
202
+ break;
203
+ }
204
+ chunks.push(next.value);
205
+ size += next.value.length;
206
+ }
207
+ } catch {
208
+ } finally {
209
+ clearTimeout(timer);
210
+ void reader.cancel().catch(() => {
211
+ });
212
+ }
213
+ return {
214
+ bytes: Buffer.concat(chunks).subarray(0, RESPONSE_BODY_BYTES),
215
+ cut: !ended || size > RESPONSE_BODY_BYTES
216
+ };
217
+ }
218
+ async function describeResponse(response) {
219
+ const headers = [...response.headers].map(([name, value]) => [
220
+ printable(name),
221
+ SENSITIVE_HEADER.test(name) ? HIDDEN_VALUE : printable(value)
222
+ ]);
223
+ const detail = { statusText: printable(response.statusText), headers };
224
+ if (!response.body) return detail;
225
+ const type = printable(response.headers.get("content-type")?.split(";")[0] ?? "").trim();
226
+ if (type && !TEXT_TYPE.test(type)) {
227
+ void response.body.cancel().catch(() => {
228
+ });
229
+ return { ...detail, bodyNote: `${type} body, not shown` };
230
+ }
231
+ const { bytes, cut } = await readStart(response.body);
232
+ if (bytes.length === 0) return detail;
233
+ if (bytes.includes(NUL)) return { ...detail, bodyNote: "binary body, not shown" };
234
+ const decoded = new TextDecoder().decode(bytes).replaceAll(" ", " ");
235
+ const text = printable(cut ? decoded.replace(new RegExp(`${REPLACEMENT}+$`), "") : decoded, "\n");
236
+ const body = text.trimEnd();
237
+ if (!body) return detail;
238
+ return cut ? { ...detail, body, bodyCut: true } : { ...detail, body };
239
+ }
240
+
165
241
  // src/checkers/http.ts
166
242
  function targetUrl(service) {
167
243
  const bareIpv6 = service.host.includes(":") && !service.host.startsWith("[");
@@ -183,7 +259,7 @@ function expectsRedirect(expect) {
183
259
  }
184
260
  var httpChecker = {
185
261
  type: "http",
186
- async check(name, service, { timeoutMs, signal }) {
262
+ async check(name, service, { timeoutMs, signal, detail }) {
187
263
  const target = targetUrl(service);
188
264
  const base = { name, type: "http", target };
189
265
  const started = performance.now();
@@ -196,10 +272,20 @@ var httpChecker = {
196
272
  redirect: expectsRedirect(service.expect) ? "manual" : "follow"
197
273
  });
198
274
  const latencyMs = elapsed();
199
- await response.body?.cancel();
200
275
  const { status } = response;
201
- if (isExpected(service.expect, status)) return { ...base, ok: true, status, latencyMs };
202
- return { ...base, ok: false, status, latencyMs, ...diagnoseStatus(service, status) };
276
+ const described = detail ? { response: await describeResponse(response) } : {};
277
+ if (!detail) await response.body?.cancel();
278
+ if (isExpected(service.expect, status)) {
279
+ return { ...base, ok: true, status, latencyMs, ...described };
280
+ }
281
+ return {
282
+ ...base,
283
+ ok: false,
284
+ status,
285
+ latencyMs,
286
+ ...diagnoseStatus(service, status),
287
+ ...described
288
+ };
203
289
  } catch (error) {
204
290
  const diagnosis = diagnoseFailure(error, service, timeoutMs);
205
291
  return { ...base, ok: false, latencyMs: elapsed(), ...diagnosis };
@@ -251,7 +337,8 @@ async function runChecks(config, names, options, skipped = /* @__PURE__ */ new S
251
337
  }
252
338
  return getChecker(type).check(name, service, {
253
339
  timeoutMs: resolveTimeoutMs(service, options),
254
- signal: options.signal
340
+ signal: options.signal,
341
+ detail: options.detail
255
342
  });
256
343
  })
257
344
  );
@@ -417,6 +504,7 @@ export {
417
504
  DEFAULT_MAKEFILE,
418
505
  DEFAULT_TIMEOUT_SECONDS,
419
506
  DASHBOARD_INTERVAL_SECONDS,
507
+ DASHBOARD_HISTORY_LENGTH,
420
508
  DASHBOARD_INTERVAL_CHOICES_SECONDS,
421
509
  DASHBOARD_TIMEOUT_CHOICES_SECONDS,
422
510
  MAX_TIMEOUT_SECONDS,
package/dist/cli.js CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  serviceSchemaV1,
51
51
  stripStyles,
52
52
  targetUrl
53
- } from "./chunk-6UGVXAZY.js";
53
+ } from "./chunk-VSQVZK7F.js";
54
54
  import "./chunk-M3ASEG5F.js";
55
55
 
56
56
  // src/cli.ts
@@ -624,7 +624,7 @@ async function writeLock(paths, lock) {
624
624
  }
625
625
 
626
626
  // src/version.ts
627
- var CLI_VERSION = true ? "0.9.0" : "0.0.0-dev";
627
+ var CLI_VERSION = true ? "0.10.0" : "0.0.0-dev";
628
628
 
629
629
  // src/makefile/generator.ts
630
630
  import { createHash } from "crypto";
@@ -1796,7 +1796,7 @@ async function openedDashboard(rawArgs) {
1796
1796
  if (!asked) return false;
1797
1797
  throw new ProbeError("USAGE", "No services yet. Add one: probe add api -p 3000");
1798
1798
  }
1799
- const { openDashboard } = await import("./open-YISUDM6V.js");
1799
+ const { openDashboard } = await import("./open-W2TWG636.js");
1800
1800
  let settings = initialDashboardSettings(askedMs, await readDashboardPreferences(paths));
1801
1801
  const saves = createSaveQueue();
1802
1802
  const local = `${PROBE_DIR}/${LOCAL_FILE}`;
@@ -1,6 +1,7 @@
1
1
  import { createRequire as __createRequire } from "node:module";
2
2
  const require = __createRequire(import.meta.url);
3
3
  import {
4
+ DASHBOARD_HISTORY_LENGTH,
4
5
  DASHBOARD_INTERVAL_CHOICES_SECONDS,
5
6
  DASHBOARD_TIMEOUT_CHOICES_SECONDS,
6
7
  createColors,
@@ -17,7 +18,7 @@ import {
17
18
  runChecks,
18
19
  targetUrl,
19
20
  truncate
20
- } from "./chunk-6UGVXAZY.js";
21
+ } from "./chunk-VSQVZK7F.js";
21
22
  import {
22
23
  __commonJS,
23
24
  __export,
@@ -18596,7 +18597,7 @@ var Box_default = Box;
18596
18597
 
18597
18598
  // node_modules/.pnpm/ink@8.0.0_@types+react@19.3.0_react@19.3.1/node_modules/ink/build/components/Text.js
18598
18599
  var import_react12 = __toESM(require_react(), 1);
18599
- function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = "wrap", children, "aria-label": ariaLabel, "aria-hidden": ariaHidden = false }) {
18600
+ function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap: wrap2 = "wrap", children, "aria-label": ariaLabel, "aria-hidden": ariaHidden = false }) {
18600
18601
  const { isScreenReaderEnabled } = (0, import_react12.use)(AccessibilityContext);
18601
18602
  const inheritedBackgroundColor = (0, import_react12.use)(BackgroundContext);
18602
18603
  const effectiveBackgroundColor = backgroundColor ?? inheritedBackgroundColor;
@@ -18634,7 +18635,7 @@ function Text({ color, backgroundColor, dimColor = false, bold = false, italic =
18634
18635
  flexGrow: 0,
18635
18636
  flexShrink: 1,
18636
18637
  flexDirection: "row",
18637
- textWrap: wrap
18638
+ textWrap: wrap2
18638
18639
  }, internal_transform: isScreenReaderEnabled ? void 0 : transform }, childrenOrAriaLabel)
18639
18640
  );
18640
18641
  }
@@ -20312,6 +20313,111 @@ ${command}`;
20312
20313
  // src/dashboard/Dashboard.tsx
20313
20314
  var import_react36 = __toESM(require_react(), 1);
20314
20315
 
20316
+ // src/dashboard/detail.ts
20317
+ var BLOCKS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
20318
+ var HEADER_LINES = 6;
20319
+ var BODY_LINES = 5;
20320
+ var HINT_LINES = 3;
20321
+ var TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
20322
+ "connection",
20323
+ "content-length",
20324
+ "date",
20325
+ "keep-alive",
20326
+ "transfer-encoding"
20327
+ ]);
20328
+ function recordLatencies(history, results) {
20329
+ return Object.fromEntries(
20330
+ results.map(({ name, status, skipped, latencyMs }) => {
20331
+ const before = history[name] ?? [];
20332
+ const answered = status !== void 0 && !skipped;
20333
+ return [name, answered ? [...before, latencyMs].slice(-DASHBOARD_HISTORY_LENGTH) : before];
20334
+ })
20335
+ );
20336
+ }
20337
+ function sparkline(values, unicode, width) {
20338
+ if (values.length === 0) return "";
20339
+ const low = Math.min(...values);
20340
+ const high = Math.max(...values);
20341
+ const range = low === high ? `${low}ms` : `${low}${unicode ? "\u2013" : "-"}${high}ms`;
20342
+ if (!unicode) return `${range} over ${values.length} ${values.length === 1 ? "check" : "checks"}`;
20343
+ const room = width - range.length - 1;
20344
+ if (room < 1) return range;
20345
+ const top = BLOCKS.length - 1;
20346
+ const bars = values.slice(-room).map((value) => BLOCKS[high === low ? 0 : Math.round((value - low) / (high - low) * top)]).join("");
20347
+ return `${bars} ${range}`;
20348
+ }
20349
+ function wrap(text, width, maxLines) {
20350
+ const lines = [];
20351
+ let line = "";
20352
+ for (const word of text.split(" ")) {
20353
+ if (line && line.length + 1 + word.length > width) {
20354
+ lines.push(line);
20355
+ line = word;
20356
+ } else line = line ? `${line} ${word}` : word;
20357
+ }
20358
+ lines.push(line);
20359
+ const kept = lines.slice(0, maxLines).map((kept2) => truncate(kept2, width));
20360
+ if (lines.length > maxLines) kept[maxLines - 1] = truncate(`${kept[maxLines - 1]} \u2026`, width);
20361
+ return kept;
20362
+ }
20363
+ function detailLines(result, history, { unicode, width, room }) {
20364
+ const dot = unicode ? "\xB7" : "-";
20365
+ const more = unicode ? "\u2026" : "...";
20366
+ const cut = (text) => truncate(text, width);
20367
+ if (result.skipped) return [{ id: "status", text: cut("skipped: not checked"), tone: "dim" }];
20368
+ const { response, status } = result;
20369
+ const tone = result.ok ? "good" : "bad";
20370
+ const lines = [];
20371
+ if (status === void 0) lines.push({ id: "status", text: cut(result.error ?? "down"), tone });
20372
+ else {
20373
+ const answer = `${status} ${response?.statusText ?? ""}`.trim();
20374
+ lines.push({ id: "status", text: cut(`${answer} ${dot} ${result.latencyMs}ms`), tone });
20375
+ if (result.error) lines.push({ id: "error", text: cut(result.error), tone });
20376
+ }
20377
+ if (result.hint) {
20378
+ for (const [index, text] of wrap(result.hint, width, HINT_LINES).entries())
20379
+ lines.push({ id: `hint ${index}`, text, tone: "dim" });
20380
+ }
20381
+ const latencies = sparkline(history, unicode, width);
20382
+ if (latencies) lines.push({ id: "latencies", text: cut(latencies), tone: "dim" });
20383
+ if (!response) return lines;
20384
+ const transport = ([name]) => TRANSPORT_HEADERS.has(name.toLowerCase());
20385
+ const headers = [
20386
+ ...response.headers.filter((header) => !transport(header)),
20387
+ ...response.headers.filter(transport)
20388
+ ].map(([name, value]) => `${name}: ${value}`);
20389
+ const body = response.bodyNote ? [response.bodyNote] : response.body?.split("\n") ?? [];
20390
+ let headerRoom = Math.min(headers.length, HEADER_LINES);
20391
+ let bodyRoom = Math.min(body.length, BODY_LINES);
20392
+ const left = Math.max(0, room - lines.length);
20393
+ while (headerRoom + bodyRoom > left) {
20394
+ if (bodyRoom > 1 && bodyRoom >= headerRoom) bodyRoom -= 1;
20395
+ else if (headerRoom > 0) headerRoom -= 1;
20396
+ else bodyRoom -= 1;
20397
+ }
20398
+ const hiddenHeaders = headers.length - headerRoom;
20399
+ for (const [index, header] of headers.slice(0, headerRoom).entries()) {
20400
+ const last = index === headerRoom - 1 && hiddenHeaders > 0;
20401
+ lines.push(
20402
+ last ? {
20403
+ id: `header ${index}`,
20404
+ text: cut(`${more} ${hiddenHeaders + 1} more headers`),
20405
+ tone: "dim"
20406
+ } : { id: `header ${index}`, text: cut(header), tone: "dim" }
20407
+ );
20408
+ }
20409
+ const cutBody = body.length > bodyRoom || response.bodyCut;
20410
+ for (const [index, text] of body.slice(0, bodyRoom).entries()) {
20411
+ const last = index === bodyRoom - 1 && cutBody;
20412
+ lines.push({
20413
+ id: `body ${index}`,
20414
+ text: last ? `${truncate(text, Math.max(1, width - more.length - 1))} ${more}`.trim() : cut(text),
20415
+ tone: response.bodyNote ? "dim" : void 0
20416
+ });
20417
+ }
20418
+ return lines;
20419
+ }
20420
+
20315
20421
  // src/dashboard/form.ts
20316
20422
  var ROWS = [
20317
20423
  { key: "name", label: "Name", placeholder: "api" },
@@ -20357,16 +20463,24 @@ var list = (binding) => ({
20357
20463
  ...binding
20358
20464
  });
20359
20465
  var BINDINGS = [
20360
- list({ keys: "\u2191\u2193 jk", ascii: "jk", action: "move", help: "Move", drop: 1 }),
20361
- list({ keys: "r", action: "check", help: "Check now", barless: true }),
20362
- list({ keys: "\u21B5", ascii: "enter", action: "service", help: "Service settings", drop: 4 }),
20363
- list({ keys: "u", action: "open", help: "Open its URL", barless: true }),
20364
- list({ keys: "c", action: "copy", help: "Copy curl command", barless: true }),
20365
- list({ keys: "e", action: "edit", help: "Edit the service", barless: true }),
20366
- list({ keys: "d", action: "remove", help: "Remove it", barless: true }),
20367
- list({ keys: "a", action: "add", help: "Add a service", drop: 2 }),
20368
- list({ keys: "/", action: "filter", help: "Filter by name", drop: 3 }),
20369
- list({ keys: "o", action: "options", help: "Options", barless: true }),
20466
+ list({ keys: "\u2191\u2193 jk", ascii: "jk", action: "move", help: "Move", drop: 2 }),
20467
+ list({ keys: "r", action: "check", help: "Check now", barless: true, command: "check" }),
20468
+ list({
20469
+ keys: "\u21B5",
20470
+ ascii: "enter",
20471
+ action: "service",
20472
+ help: "Service detail",
20473
+ drop: 5,
20474
+ command: "detail"
20475
+ }),
20476
+ list({ keys: "u", action: "open", help: "Open its URL", barless: true, command: "open" }),
20477
+ list({ keys: "c", action: "copy", help: "Copy curl command", barless: true, command: "copy" }),
20478
+ list({ keys: "e", action: "edit", help: "Edit the service", barless: true, command: "edit" }),
20479
+ list({ keys: "d", action: "remove", help: "Remove it", barless: true, command: "remove" }),
20480
+ list({ keys: "a", action: "add", help: "Add a service", drop: 3, command: "add" }),
20481
+ list({ keys: "/", action: "filter", help: "Filter by name", drop: 4, command: "filter" }),
20482
+ list({ keys: ":", action: "commands", help: "Find a command", drop: 1 }),
20483
+ list({ keys: "o", action: "options", help: "Options", barless: true, command: "options" }),
20370
20484
  {
20371
20485
  screens: ["panel"],
20372
20486
  section: "Panels",
@@ -20404,6 +20518,9 @@ var BINDINGS = [
20404
20518
  help: "Keep the filter"
20405
20519
  },
20406
20520
  { screens: ["filter"], section: "Filter", keys: "esc", action: "clear", help: "Clear it" },
20521
+ { screens: ["palette"], keys: "\u2191\u2193", ascii: "arrows", action: "move", drop: 1 },
20522
+ { screens: ["palette"], keys: "\u21B5", ascii: "enter", action: "run" },
20523
+ { screens: ["palette"], keys: "esc", action: "close" },
20407
20524
  {
20408
20525
  screens: ["form"],
20409
20526
  section: "Add and edit",
@@ -20430,14 +20547,16 @@ var BINDINGS = [
20430
20547
  section: "Outside a text field",
20431
20548
  keys: "?",
20432
20549
  action: "help",
20433
- help: "This help"
20550
+ help: "This help",
20551
+ command: "help"
20434
20552
  },
20435
20553
  {
20436
20554
  screens: ["list", "panel", "help"],
20437
20555
  section: "Outside a text field",
20438
20556
  keys: "q",
20439
20557
  action: "quit",
20440
- help: "Quit"
20558
+ help: "Quit",
20559
+ command: "quit"
20441
20560
  }
20442
20561
  ];
20443
20562
  var SECTIONS = [
@@ -20459,6 +20578,17 @@ function helpBar(screen, unicode, columns) {
20459
20578
  }
20460
20579
  return line();
20461
20580
  }
20581
+ function keyCommand(input, enter) {
20582
+ return BINDINGS.find(({ keys, command }) => command && (enter ? keys === "\u21B5" : keys === input))?.command;
20583
+ }
20584
+ function paletteEntries(unicode, query = "") {
20585
+ const wanted = query.trim().toLowerCase();
20586
+ return BINDINGS.flatMap((binding) => {
20587
+ const { command, help, action } = binding;
20588
+ if (!command || !help || !`${help} ${action}`.toLowerCase().includes(wanted)) return [];
20589
+ return [{ command, label: help, keys: written(binding, unicode) }];
20590
+ });
20591
+ }
20462
20592
  var HELP_GAP = 3;
20463
20593
  function sectionLines(sections, unicode) {
20464
20594
  const described = BINDINGS.filter(({ help }) => help !== void 0);
@@ -20539,6 +20669,8 @@ var TIMEOUT_ROW = SERVICE_ROWS.indexOf("Timeout");
20539
20669
  var NO_TIMEOUTS = {};
20540
20670
  var MARKER_WIDTH = 2;
20541
20671
  var PANEL_WIDTH = 30;
20672
+ var DETAIL_WIDTH = 60;
20673
+ var DEFAULT_ROWS = 24;
20542
20674
  var FORM_WIDTH = 48;
20543
20675
  var CURSOR = "_";
20544
20676
  var PANEL_FRAME_WIDTH = 4;
@@ -20563,8 +20695,9 @@ function Dashboard({
20563
20695
  onServiceWrite
20564
20696
  }) {
20565
20697
  const { exit } = useApp();
20566
- const { columns } = use_window_size_default();
20698
+ const { columns, rows: windowRows } = use_window_size_default();
20567
20699
  const [latest, setLatest] = (0, import_react36.useState)(null);
20700
+ const [history, setHistory] = (0, import_react36.useState)({});
20568
20701
  const [selected, setSelected] = (0, import_react36.useState)(0);
20569
20702
  const [settings, setSettings] = (0, import_react36.useState)(initial);
20570
20703
  const [skipped, setSkipped] = (0, import_react36.useState)(() => new Set(initiallySkipped));
@@ -20573,6 +20706,7 @@ function Dashboard({
20573
20706
  const [dialog, setDialog] = (0, import_react36.useState)(null);
20574
20707
  const [filter, setFilter] = (0, import_react36.useState)("");
20575
20708
  const [filtering, setFiltering] = (0, import_react36.useState)(false);
20709
+ const [palette, setPalette] = (0, import_react36.useState)(null);
20576
20710
  const [writes, setWrites] = (0, import_react36.useState)(0);
20577
20711
  const [help, setHelp] = (0, import_react36.useState)(false);
20578
20712
  const [unsaved, setUnsaved] = (0, import_react36.useState)();
@@ -20591,6 +20725,7 @@ function Dashboard({
20591
20725
  const next = await check2(signal, timeoutMs, skipped, timeouts);
20592
20726
  if (signal.aborted) return;
20593
20727
  setLatest(next);
20728
+ setHistory((before) => recordLatencies(before, next));
20594
20729
  await new Promise((resolve) => {
20595
20730
  const timer = setTimeout(resolve, intervalMs);
20596
20731
  skipPause.current = () => {
@@ -20634,8 +20769,8 @@ ${outcome.warning}` : done);
20634
20769
  else if (key === "backspace" && field) edit(form.values[field].slice(0, -1));
20635
20770
  else if (typed && field) edit(form.values[field] + typed);
20636
20771
  else if (key === "return") {
20637
- const taken = (latest ?? []).map(({ name }) => name);
20638
- const problem = formProblem(rows2, form.values, taken);
20772
+ const taken2 = (latest ?? []).map(({ name }) => name);
20773
+ const problem = formProblem(rows2, form.values, taken2);
20639
20774
  if (problem) return setDialog({ ...form, row: problem.row, problem: problem.message });
20640
20775
  setDialog(null);
20641
20776
  const fields = formFields(form.values);
@@ -20678,6 +20813,54 @@ ${outcome.warning}` : done);
20678
20813
  setTimeouts({ ...timeouts, [name]: value });
20679
20814
  track(onTimeoutChange?.(name, value));
20680
20815
  };
20816
+ const run = (command) => {
20817
+ const name = shown[current]?.name;
20818
+ switch (command) {
20819
+ case "check":
20820
+ return skipPause.current();
20821
+ case "options":
20822
+ return setPanel({ kind: "options", row: 0 });
20823
+ case "detail":
20824
+ return name ? setPanel({ kind: "service", name, row: 0 }) : void 0;
20825
+ case "open":
20826
+ return name ? void onOpen?.(name).then(setNotice) : void 0;
20827
+ case "copy":
20828
+ return name ? void onCopy?.(name, timeouts[name]).then(setNotice) : void 0;
20829
+ case "filter":
20830
+ return setFiltering(true);
20831
+ case "add":
20832
+ return onServiceWrite ? setDialog({ kind: "form", row: 0, values: EMPTY_FORM }) : void 0;
20833
+ case "edit": {
20834
+ const fields = onServiceWrite && name ? serviceFields?.(name) : void 0;
20835
+ if (fields) setDialog({ kind: "form", editing: name, row: 0, values: formValues(fields) });
20836
+ return;
20837
+ }
20838
+ case "remove":
20839
+ return onServiceWrite && name ? setDialog({ kind: "confirm", name }) : void 0;
20840
+ case "help":
20841
+ return setHelp(true);
20842
+ case "quit":
20843
+ return exit();
20844
+ default:
20845
+ return command;
20846
+ }
20847
+ };
20848
+ const typePalette = ({ query, row }, keystroke) => {
20849
+ const { key } = keystroke;
20850
+ const typed = typedCharacter(keystroke);
20851
+ const entries = paletteEntries(terminal.unicode, query);
20852
+ if (key === "escape") setPalette(null);
20853
+ else if (key === "return") {
20854
+ const entry = entries[row];
20855
+ if (!entry) return;
20856
+ setPalette(null);
20857
+ run(entry.command);
20858
+ } else if (key === "up") setPalette({ query, row: Math.max(0, row - 1) });
20859
+ else if (key === "down")
20860
+ setPalette({ query, row: Math.max(0, Math.min(entries.length - 1, row + 1)) });
20861
+ else if (key === "backspace") setPalette({ query: query.slice(0, -1), row: 0 });
20862
+ else if (typed) setPalette({ query: query + typed, row: 0 });
20863
+ };
20681
20864
  const press = (keystroke) => {
20682
20865
  const { input, key } = keystroke;
20683
20866
  const up2 = key === "up" || input === "k";
@@ -20685,35 +20868,23 @@ ${outcome.warning}` : done);
20685
20868
  const left = key === "left" || input === "h";
20686
20869
  const right = key === "right" || input === "l";
20687
20870
  const enter = key === "return";
20688
- const selectedName = shown[current]?.name;
20689
20871
  setNotice(void 0);
20690
20872
  if (dialog?.kind === "form") typeInForm(dialog, keystroke);
20691
20873
  else if (dialog) {
20692
20874
  setDialog(null);
20693
20875
  if (input === "y")
20694
20876
  void write({ kind: "remove", name: dialog.name }, `Removed ${dialog.name}`);
20695
- } else if (filtering) typeFilter(keystroke);
20877
+ } else if (palette) typePalette(palette, keystroke);
20878
+ else if (filtering) typeFilter(keystroke);
20696
20879
  else if (input === "q") exit();
20697
20880
  else if (help) {
20698
20881
  if (input === "?" || key === "escape") setHelp(false);
20699
20882
  } else if (input === "?") setHelp(true);
20700
20883
  else if (panel === null) {
20701
- if (input === "r") skipPause.current();
20702
- else if (input === "o") setPanel({ kind: "options", row: 0 });
20703
- else if (enter && selectedName) setPanel({ kind: "service", name: selectedName, row: 0 });
20704
- else if (input === "u" && selectedName) void onOpen?.(selectedName).then(setNotice);
20705
- else if (input === "c" && selectedName)
20706
- void onCopy?.(selectedName, timeouts[selectedName]).then(setNotice);
20707
- else if (input === "/") setFiltering(true);
20884
+ const command = keyCommand(input, enter);
20885
+ if (command) run(command);
20886
+ else if (input === ":") setPalette({ query: "", row: 0 });
20708
20887
  else if (key === "escape") setFilter("");
20709
- else if (input === "a" && onServiceWrite)
20710
- setDialog({ kind: "form", row: 0, values: EMPTY_FORM });
20711
- else if (input === "e" && onServiceWrite && selectedName) {
20712
- const fields = serviceFields?.(selectedName);
20713
- if (fields)
20714
- setDialog({ kind: "form", editing: selectedName, row: 0, values: formValues(fields) });
20715
- } else if (input === "d" && onServiceWrite && selectedName)
20716
- setDialog({ kind: "confirm", name: selectedName });
20717
20888
  else if (up2) setSelected(Math.max(0, current - 1));
20718
20889
  else if (down) setSelected(Math.min(last, current + 1));
20719
20890
  } else if (key === "escape" || (panel.kind === "options" ? input === "o" : enter))
@@ -20775,11 +20946,12 @@ ${outcome.warning}` : done);
20775
20946
  ] });
20776
20947
  }
20777
20948
  if (!latest) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: cut("probe: checking\u2026") });
20778
- const results = shown.map((result) => {
20949
+ const asShown = (result) => {
20779
20950
  if (result.skipped || !skipped.has(result.name)) return result;
20780
20951
  const { name, type, target } = result;
20781
20952
  return { name, type, target, ok: true, skipped: true, latencyMs: 0 };
20782
- });
20953
+ };
20954
+ const results = shown.map(asShown);
20783
20955
  const all = latest.filter((result) => !result.skipped && !skipped.has(result.name));
20784
20956
  const up = all.filter((result) => result.ok).length;
20785
20957
  const aside = latest.length - all.length;
@@ -20790,13 +20962,25 @@ ${outcome.warning}` : done);
20790
20962
  `every ${seconds(intervalMs)}`,
20791
20963
  ...filter && !filtering ? [`filter: ${filter}`] : []
20792
20964
  ].join(` ${dot} `);
20793
- const rows = results.length === 0 ? [] : formatCheckResults(results, {
20794
- ...terminal,
20795
- columns: Math.max(1, columns - MARKER_WIDTH)
20796
- }).split("\n");
20965
+ const rows = results.length === 0 ? [] : (
20966
+ // One line per service: the hint of the selected one goes under the list.
20967
+ formatCheckResults(
20968
+ results.map(({ hint: _, ...result }) => result),
20969
+ { ...terminal, columns: Math.max(1, columns - MARKER_WIDTH) }
20970
+ ).split("\n")
20971
+ );
20797
20972
  const empty = filter ? `no service matches "${filter}"` : onServiceWrite ? "no service: press a to add one" : "no service";
20798
20973
  const focused = results[current];
20799
- const panelWidth = Math.min(PANEL_WIDTH, columns);
20974
+ const panelWidth = Math.min(panel?.kind === "service" ? DETAIL_WIDTH : PANEL_WIDTH, columns);
20975
+ const panelRoom = Math.max(1, panelWidth - PANEL_FRAME_WIDTH);
20976
+ const detailed = panel?.kind === "service" ? latest.find(({ name }) => name === panel.name) : void 0;
20977
+ const taken = 1 + Math.max(1, rows.length) + 1 + 2 + 1 + 1 + SERVICE_ROWS.length + 1 + 1;
20978
+ const detail = detailed ? detailLines(asShown(detailed), history[detailed.name] ?? [], {
20979
+ unicode,
20980
+ width: panelRoom,
20981
+ room: (windowRows || DEFAULT_ROWS) - taken - (unsaved === void 0 ? 0 : 1)
20982
+ }) : [];
20983
+ const toned = ({ text, tone }) => tone === "good" ? colors.green(text) : tone === "bad" ? colors.red(text) : tone === "dim" ? colors.dim(text) : text;
20800
20984
  const panelRows = panel?.kind === "options" ? SETTING_ROWS.map((row) => [row.label, seconds(settings[row.key])]) : panel ? [
20801
20985
  ["Skip", skipped.has(panel.name) ? "yes" : "no"],
20802
20986
  [
@@ -20805,8 +20989,15 @@ ${outcome.warning}` : done);
20805
20989
  ]
20806
20990
  ] : [];
20807
20991
  const labelWidth = Math.max(0, ...panelRows.map(([label]) => label.length));
20808
- const screen = dialog?.kind === "form" ? "form" : dialog ? "confirm" : filtering ? "filter" : panel ? "panel" : "list";
20992
+ const screen = dialog?.kind === "form" ? "form" : dialog ? "confirm" : palette ? "palette" : filtering ? "filter" : panel ? "panel" : "list";
20809
20993
  const hint = helpBar(screen, unicode, columns);
20994
+ const commands = palette ? paletteEntries(unicode, palette.query) : [];
20995
+ const commandWidth = Math.max(0, ...commands.map(({ label }) => label.length));
20996
+ const paletteRoom = Math.max(
20997
+ 1,
20998
+ (windowRows || DEFAULT_ROWS) - (1 + Math.max(1, rows.length) + (focused ? 1 : 0) + (focused?.hint ? 1 : 0) + 1 + 1 + 1) - (unsaved === void 0 ? 0 : 1)
20999
+ );
21000
+ const firstCommand = Math.max(0, (palette?.row ?? 0) - paletteRoom + 1);
20810
21001
  const formWidth = Math.min(FORM_WIDTH, columns);
20811
21002
  const formLabels = dialog?.kind === "form" ? formRows(dialog.editing === void 0) : [];
20812
21003
  const formLabelWidth = Math.max(0, ...formLabels.map(({ label }) => label.length));
@@ -20820,6 +21011,7 @@ ${outcome.warning}` : done);
20820
21011
  focused && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.dim(
20821
21012
  cut(focused.error ? `${focused.target} ${dot} ${focused.error}` : focused.target)
20822
21013
  ) }),
21014
+ focused?.hint && panel?.kind !== "service" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.dim(cut(focused.hint)) }),
20823
21015
  panel !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
20824
21016
  Box_default,
20825
21017
  {
@@ -20828,20 +21020,22 @@ ${outcome.warning}` : done);
20828
21020
  borderStyle: unicode ? "single" : "classic",
20829
21021
  paddingX: 1,
20830
21022
  children: [
20831
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.bold(
20832
- truncate(
20833
- panel.kind === "options" ? "Options" : panel.name,
20834
- Math.max(1, panelWidth - PANEL_FRAME_WIDTH)
20835
- )
20836
- ) }),
21023
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.bold(truncate(panel.kind === "options" ? "Options" : panel.name, panelRoom)) }),
21024
+ detail.map((line) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: toned(line) }, line.id)),
21025
+ detail.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.dim((unicode ? "\u2500" : "-").repeat(panelRoom)) }),
20837
21026
  panelRows.map(([label, value], index) => {
20838
21027
  const line = `${index === panel.row ? marker : " "}${label.padEnd(labelWidth)} ${unicode ? `\u2039 ${value} \u203A` : `< ${value} >`}`;
20839
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: truncate(line, Math.max(1, panelWidth - PANEL_FRAME_WIDTH)) }, label);
21028
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: truncate(line, panelRoom) }, label);
20840
21029
  })
20841
21030
  ]
20842
21031
  }
20843
21032
  ),
20844
21033
  filtering && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: cut(`Filter: ${filter}${CURSOR}`) }),
21034
+ palette && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: cut(`: ${palette.query}${CURSOR}`) }),
21035
+ palette && commands.slice(firstCommand, firstCommand + paletteRoom).map(({ command, label, keys }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: cut(
21036
+ `${commands[palette.row]?.command === command ? marker : " "}${label.padEnd(commandWidth)} ${keys}`
21037
+ ) }, command)),
21038
+ palette && commands.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.dim(cut(`no command matches "${palette.query}"`)) }),
20845
21039
  dialog?.kind === "confirm" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: colors.bold(cut(`Remove ${dialog.name} from the config?`)) }),
20846
21040
  dialog?.kind === "form" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
20847
21041
  Box_default,
@@ -20896,7 +21090,7 @@ async function openDashboard(initial, terminal, { onServiceWrite, ...state }) {
20896
21090
  { ...service, timeout: timeouts2[name] }
20897
21091
  ])
20898
21092
  );
20899
- return runChecks({ ...config, services }, [], { timeoutMs, signal }, skipped);
21093
+ return runChecks({ ...config, services }, [], { timeoutMs, signal, detail: true }, skipped);
20900
21094
  };
20901
21095
  const timeouts = Object.fromEntries(
20902
21096
  Object.entries(config.services).flatMap(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninhache/probe",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Health checks for your local dev services, from the CLI or a generated Makefile",
5
5
  "license": "MIT",
6
6
  "author": "Ninhache",