@podway/relay 0.2.5 → 0.2.6

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/FAQ.md CHANGED
@@ -115,3 +115,22 @@ rm -rf ~/.npm/_npx
115
115
  npm i -g @podway/relay@latest
116
116
  relay status
117
117
  ```
118
+
119
+ ## How do I see what the relay did without the web dashboard?
120
+
121
+ `relay status` prints it as tables — useful on a NAS where the dashboard's local page is out of reach:
122
+
123
+ ```
124
+ STATE UPTIME LINK REQ 24h OK% DATA
125
+ running 2d 4h connected 412 98% 61 MB
126
+
127
+ POD REQ OK% LAST
128
+ afisha crawler 301 99% 12 s ago
129
+ crypto sail club 88 93% 3 min ago
130
+
131
+ SITE REQ OK% LAST ERROR
132
+ polymarket.com 145 97% 403 refused, 26 min ago
133
+ ```
134
+
135
+ `--since 1h|24h|7d` changes the window, `--all` shows every row, `--json` is for scripts. DATA counts
136
+ tunnel traffic only (plain fetches have no size recorded).
package/README.md CHANGED
@@ -46,7 +46,7 @@ Once started, the relay runs in the background and handles both modes.
46
46
  | `relay start --code <code> --gateway <url> --accept` | start the relay in the background |
47
47
  | `relay login <domain>` | allow signed-in fetches for one domain |
48
48
  | `relay dashboard` | open the local command center |
49
- | `relay status` | show the relay, gateway, and signed-in site status |
49
+ | `relay status` | the relay at a glance, then requests per pod and per site (OK %, last error); `--since 1h\|24h\|7d`, `--all`, `--json` |
50
50
  | `relay stop` | stop it |
51
51
  | `relay reset` | remove saved sessions, logins, and pairing |
52
52
 
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { DISCLOSURE } from "./disclosure.js";
7
7
  import { c, rows } from "./colors.js";
8
8
  import { normalizeDomain } from "./domain.js";
9
9
  import { stopPid } from "./stop.js";
10
+ import { renderStatus, statusData, WINDOWS } from "./status.js";
10
11
  // Only `start` (the daemon) and `dashboard` need the transport, browser and dashboard modules.
11
12
  // They load lazily so `relay stop` / `relay status` stay quick on a slow NAS CPU (a DS118 runs the
12
13
  // boot task's `relay stop` before every start).
@@ -367,22 +368,27 @@ async function cmdDashboard(a) {
367
368
  openBrowser(url);
368
369
  await new Promise(() => { }); // stay up until Ctrl-C
369
370
  }
370
- async function cmdStatus() {
371
+ async function cmdStatus(a = []) {
372
+ const flag = (n) => a.includes(n);
373
+ const sinceArg = a[a.indexOf("--since") + 1];
374
+ const window = a.includes("--since") && sinceArg && sinceArg in WINDOWS ? sinceArg : "24h";
371
375
  const pid = isRunning();
372
376
  const cfg = load();
373
- // Pad wider than the longest label ("dashboard", 9) so every value has a gutter —
374
- // padEnd(9) left "dashboard" flush against its URL.
375
- const label = (s) => c.dim(s.padEnd(11));
377
+ let startedAt;
378
+ try {
379
+ startedAt = JSON.parse(readFileSync(dashboardRuntimeFile(), "utf8")).startedAt;
380
+ }
381
+ catch { }
382
+ // audit.js is fs-only; reload() reads the saved history without pruning or writing.
383
+ const { RelayEventStore } = await import("./audit.js");
384
+ const store = new RelayEventStore({ retentionDays: 30 });
385
+ store.reload();
386
+ const data = statusData(store.query({ limit: 10_000 }), { state: pid ? "running" : "stopped", startedAt, link: pid ? await gatewayLinkState() : undefined }, window);
387
+ if (flag("--json"))
388
+ return log(JSON.stringify({ ...data, pid, gateway: cfg.gatewayUrl ?? null, asYou: cfg.loginDomains }, null, 2));
389
+ log(renderStatus(data, { limit: flag("--all") ? Infinity : 10 }));
376
390
  log("");
377
- log(`${label("relay")}${pid ? c.green(`running`) + c.dim(` (pid ${pid})`) : c.yellow("not running")}`);
378
- // The PROCESS being alive is not the same as the gateway LINK being up — a slept host
379
- // leaves a dead link the process can't see. Ask the daemon's own dashboard for the
380
- // live state so status stops reporting "running" over a dead link.
381
- if (pid)
382
- log(`${label("link")}${await gatewayLinkState()}`);
383
- log(`${label("gateway")}${cfg.gatewayUrl ? c.cyan(cfg.gatewayUrl) : c.dim("(unset)")}`);
384
- log(`${label("dashboard")}${dashboardUrl() ? c.cyan(dashboardUrl()) : c.dim("relay dashboard opens saved history")}`);
385
- log(`${label("as-you")}${cfg.loginDomains.length ? cfg.loginDomains.join(", ") : c.dim("no sites signed in — all fetches are clean")}`);
391
+ log(c.dim(`gateway ${cfg.gatewayUrl ?? "(unset)"} · as-you ${cfg.loginDomains.length ? cfg.loginDomains.join(", ") : "none"} · --since 1h|24h|7d · --all · --json`));
386
392
  log("");
387
393
  }
388
394
  /** Live gateway-link state, read from the running daemon's local dashboard (`/data`).
@@ -392,22 +398,16 @@ async function cmdStatus() {
392
398
  async function gatewayLinkState() {
393
399
  const base = dashboardUrl();
394
400
  if (!base)
395
- return c.dim("unknown (dashboard not up yet)");
401
+ return "starting";
396
402
  try {
397
403
  const ac = new AbortController();
398
404
  const timer = setTimeout(() => ac.abort(), 1500);
399
405
  const res = await fetch(`${base}/data`, { signal: ac.signal }).finally(() => clearTimeout(timer));
400
406
  const data = (await res.json());
401
- if (data.state === "connected")
402
- return c.green("connected");
403
- if (data.state === "reconnecting")
404
- return c.yellow("reconnecting… (host asleep or offline)");
405
- if (data.state === "stopped")
406
- return c.yellow("stopped");
407
- return c.dim("unknown");
407
+ return data.state === "connected" || data.state === "reconnecting" || data.state === "stopped" ? data.state : "unknown";
408
408
  }
409
409
  catch {
410
- return c.yellow("unreachable — the relay daemon isn't responding (try: relay restart)");
410
+ return "unreachable"; // the daemon isn't answering — relay restart
411
411
  }
412
412
  }
413
413
  /** Stop-then-start, reusing the stored gateway + reconnect token. The escape hatch for a
@@ -437,7 +437,7 @@ async function dispatch(a) {
437
437
  case "start": return cmdStart(a.slice(1));
438
438
  case "login": return cmdLogin(a.slice(1));
439
439
  case "dashboard": return cmdDashboard(a.slice(1));
440
- case "status": return cmdStatus();
440
+ case "status": return cmdStatus(a.slice(1));
441
441
  case "stop": return cmdStop();
442
442
  case "restart": return cmdRestart(a.slice(1));
443
443
  case "reset":
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `relay status` stats as docker-ps style tables (owner, 2026-11-08: the web dashboard is not reachable on
3
+ * a headless NAS, so the terminal has to show what the relay did). Pure: events in, text/JSON out. It reads
4
+ * only the saved event history — no transport, browser or dashboard module — so status stays fast.
5
+ */
6
+ import type { RelayEventV2 } from "./audit.js";
7
+ export declare const WINDOWS: {
8
+ readonly "1h": 3600000;
9
+ readonly "24h": 86400000;
10
+ readonly "7d": number;
11
+ };
12
+ export type StatusWindow = keyof typeof WINDOWS;
13
+ export interface StatusHead {
14
+ state: "running" | "stopped";
15
+ startedAt?: string;
16
+ link?: string;
17
+ }
18
+ export interface StatusData {
19
+ window: StatusWindow;
20
+ state: StatusHead["state"];
21
+ uptimeMs: number | null;
22
+ link: string | null;
23
+ requests: number;
24
+ okPct: number | null;
25
+ bytes: number | null;
26
+ pods: {
27
+ name: string;
28
+ requests: number;
29
+ okPct: number;
30
+ lastAt: string;
31
+ }[];
32
+ sites: {
33
+ host: string;
34
+ requests: number;
35
+ okPct: number;
36
+ lastError: string | null;
37
+ lastErrorAt: string | null;
38
+ }[];
39
+ }
40
+ export declare function statusData(events: RelayEventV2[], head: StatusHead, window: StatusWindow, now?: number): StatusData;
41
+ export declare function ago(ms: number): string;
42
+ export declare function renderStatus(d: StatusData, opts?: {
43
+ limit: number;
44
+ now?: number;
45
+ }): string;
package/dist/status.js ADDED
@@ -0,0 +1,109 @@
1
+ import { c } from "./colors.js";
2
+ export const WINDOWS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 7 * 86_400_000 };
3
+ const ERROR_WORD = {
4
+ "site-refused": "refused",
5
+ "owner-blocked": "blocked by you",
6
+ "safety-blocked": "blocked (safety)",
7
+ "rate-limited": "rate limited",
8
+ "network-error": "network error",
9
+ };
10
+ function errorText(e) {
11
+ return [e.httpStatus, ERROR_WORD[e.outcome] ?? e.outcome].filter(Boolean).join(" ");
12
+ }
13
+ function podName(e) {
14
+ if (!e.source)
15
+ return "(unknown)";
16
+ if ("system" in e.source)
17
+ return null; // the relay's own health checks are not a pod
18
+ return e.source.podName || e.source.podId;
19
+ }
20
+ function group(events, key) {
21
+ const by = new Map();
22
+ for (const e of events) {
23
+ const k = key(e);
24
+ if (!k)
25
+ continue;
26
+ const t = Date.parse(e.startedAt);
27
+ const r = by.get(k) ?? { name: k, req: 0, ok: 0, last: 0 };
28
+ r.req++;
29
+ if (e.outcome === "ok")
30
+ r.ok++;
31
+ else if (!r.lastError || t > Date.parse(r.lastError.startedAt))
32
+ r.lastError = e;
33
+ r.last = Math.max(r.last, t);
34
+ by.set(k, r);
35
+ }
36
+ return [...by.values()].sort((a, b) => b.req - a.req || b.last - a.last);
37
+ }
38
+ const pct = (ok, n) => (n ? Math.round((ok / n) * 100) : 0);
39
+ export function statusData(events, head, window, now = Date.now()) {
40
+ const inWindow = events.filter((e) => Date.parse(e.startedAt) >= now - WINDOWS[window]);
41
+ const counted = inWindow.filter((e) => podName(e) !== null);
42
+ const ok = counted.filter((e) => e.outcome === "ok").length;
43
+ return {
44
+ window,
45
+ state: head.state,
46
+ uptimeMs: head.state === "running" && head.startedAt ? Math.max(0, now - Date.parse(head.startedAt)) : null,
47
+ link: head.state === "running" ? (head.link ?? "unknown") : null,
48
+ requests: counted.length,
49
+ okPct: counted.length ? pct(ok, counted.length) : null,
50
+ bytes: counted.some((e) => e.mode === "tunnel") ? counted.filter((e) => e.mode === "tunnel").reduce((s, e) => s + (e.bytesUp ?? 0) + (e.bytesDown ?? 0), 0) : null,
51
+ pods: group(counted, podName).map((r) => ({ name: r.name, requests: r.req, okPct: pct(r.ok, r.req), lastAt: new Date(r.last).toISOString() })),
52
+ sites: group(counted, (e) => e.host).map((r) => ({
53
+ host: r.name, requests: r.req, okPct: pct(r.ok, r.req),
54
+ lastError: r.lastError ? errorText(r.lastError) : null,
55
+ lastErrorAt: r.lastError ? r.lastError.startedAt : null,
56
+ })),
57
+ };
58
+ }
59
+ export function ago(ms) {
60
+ const s = Math.max(0, Math.round(ms / 1000));
61
+ if (s < 60)
62
+ return `${s} s ago`;
63
+ if (s < 3600)
64
+ return `${Math.round(s / 60)} min ago`;
65
+ if (s < 86_400)
66
+ return `${Math.round(s / 3600)} h ago`;
67
+ return `${Math.round(s / 86_400)} d ago`;
68
+ }
69
+ function duration(ms) {
70
+ const m = Math.floor(ms / 60_000), h = Math.floor(m / 60), d = Math.floor(h / 24);
71
+ return d ? `${d}d ${h % 24}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
72
+ }
73
+ function size(b) {
74
+ if (b < 1024)
75
+ return `${b} B`;
76
+ if (b < 1024 ** 2)
77
+ return `${Math.round(b / 1024)} KB`;
78
+ if (b < 1024 ** 3)
79
+ return `${Math.round(b / 1024 ** 2)} MB`;
80
+ return `${(b / 1024 ** 3).toFixed(1)} GB`;
81
+ }
82
+ const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s);
83
+ /** Columns padded to fixed widths that fit 80 columns; numbers right-aligned. */
84
+ function table(head, widths, right, body) {
85
+ const line = (cells) => cells.map((v, i) => (i === cells.length - 1 ? v : right[i] ? v.padStart(widths[i]) : cut(v, widths[i]).padEnd(widths[i]))).join(" ").trimEnd();
86
+ return [c.dim(line(head)), ...body.map(line)];
87
+ }
88
+ export function renderStatus(d, opts = { limit: 10 }) {
89
+ const now = opts.now ?? Date.now();
90
+ const out = [""];
91
+ const stateWord = d.state === "running" ? c.green("running") : c.yellow("stopped");
92
+ const head = table(["STATE", "UPTIME", "LINK", `REQ ${d.window}`, "OK%", "DATA"], [8, 8, 12, 8, 4, 0], [false, false, false, true, true, false], [[d.state, d.uptimeMs === null ? "—" : duration(d.uptimeMs), d.link ?? "—", String(d.requests), d.okPct === null ? "—" : `${d.okPct}%`, d.bytes === null ? "—" : size(d.bytes)]]);
93
+ head[1] = head[1].replace(/^(running|stopped)/, stateWord);
94
+ out.push(...head);
95
+ if (!d.requests) {
96
+ out.push("", c.dim(`no pod requests in the last ${d.window}`));
97
+ }
98
+ else {
99
+ out.push("");
100
+ out.push(...table(["POD", "REQ", "OK%", "LAST"], [24, 6, 4, 0], [false, true, true, false], d.pods.slice(0, opts.limit).map((p) => [p.name, String(p.requests), `${p.okPct}%`, ago(now - Date.parse(p.lastAt))])));
101
+ out.push("");
102
+ out.push(...table(["SITE", "REQ", "OK%", "LAST ERROR"], [24, 6, 4, 0], [false, true, true, false], d.sites.slice(0, opts.limit).map((s) => [s.host, String(s.requests), `${s.okPct}%`,
103
+ s.lastError ? `${s.lastError}, ${ago(now - Date.parse(s.lastErrorAt))}` : "—"])));
104
+ const hidden = Math.max(0, d.pods.length - opts.limit) + Math.max(0, d.sites.length - opts.limit);
105
+ if (hidden)
106
+ out.push("", c.dim(`${hidden} more rows — relay status --all`));
107
+ }
108
+ return out.join("\n");
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podway/relay",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Podway relay \u2014 runs a fetch relay on your machine so a pod can read pages from your network, using sessions you sign into. Install: npm i -g @podway/relay, then `relay start`.",
5
5
  "type": "module",
6
6
  "bin": {