Skip to content

Commit 6e42553

Browse files
committed
feat: slight stats updates for better results and ux
1 parent e1ccff0 commit 6e42553

3 files changed

Lines changed: 135 additions & 17 deletions

File tree

src/commands/test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ import {
2323
timingRows,
2424
seekRows,
2525
downloadRows,
26+
seekStatsRows,
27+
computeSeekStats,
2628
formatBytes,
2729
formatSpeed,
30+
formatEta,
2831
} from "../library/tables";
2932
import { sendResultsToPrivatebin } from "../functions/privatebinFunctions";
3033

@@ -51,7 +54,11 @@ function makeProgressBox(renderer: CliRenderer, title: string) {
5154
bar.content = "░".repeat(BAR_WIDTH);
5255
}
5356
const total = state.totalBytes !== null ? ` / ${formatBytes(state.totalBytes)}` : "";
54-
stats.content = `${formatBytes(state.bytes)}${total} · ${formatSpeed(state.bytesPerSecond)}`;
57+
const eta =
58+
state.totalBytes !== null && state.bytesPerSecond > 0
59+
? ` · ETA ${formatEta((state.totalBytes - state.bytes) / state.bytesPerSecond)}`
60+
: "";
61+
stats.content = `${formatBytes(state.bytes)}${total} · ${formatSpeed(state.bytesPerSecond)}${eta}`;
5562
};
5663

5764
const finish = (color: string) => {
@@ -100,6 +107,7 @@ export default defineCommand({
100107
: null;
101108

102109
const results = new Map<TimingPhase, number>();
110+
let latencyMs: number | null = null;
103111
if (!skipTimings && renderer) {
104112
const progressBox = new BoxRenderable(renderer, {
105113
borderStyle: "rounded",
@@ -115,16 +123,29 @@ export default defineCommand({
115123
rows.set(key, row);
116124
progressBox.add(row);
117125
}
126+
const latencyRow = new TextRenderable(renderer, {
127+
content: `⋯ Latency`,
128+
fg: "#888888",
129+
});
130+
progressBox.add(latencyRow);
118131
renderer.root.add(progressBox);
119132

120-
await getLinkTimings(link, undefined, (phase, ms) => {
133+
const linkTimings = await getLinkTimings(link, undefined, (phase, ms) => {
121134
results.set(phase, ms);
122135
const row = rows.get(phase);
123136
if (!row) return;
124137
const label = PHASES.find((p) => p.key === phase)?.label ?? phase;
125138
row.content = `✓ ${label.padEnd(16)} ${ms.toFixed(2)} ms`;
126139
row.fg = "#00d787";
127140
});
141+
latencyMs = linkTimings?.tcp ?? null;
142+
if (latencyMs !== null) {
143+
latencyRow.content = `✓ ${"Latency".padEnd(16)} ${latencyMs.toFixed(2)} ms`;
144+
latencyRow.fg = "#00d787";
145+
} else {
146+
latencyRow.content = `✗ Latency`;
147+
latencyRow.fg = "#ff5f5f";
148+
}
128149
}
129150

130151
const seekResults = skipSeek
@@ -166,21 +187,25 @@ export default defineCommand({
166187
? null
167188
: await sendResultsToPrivatebin({
168189
linkInfo: linkInfo,
169-
timings: !skipTimings ? Object.fromEntries(results) : undefined,
170-
seekResults: !skipSeek && seekResults ? seekResults : undefined,
190+
timings: !skipTimings
191+
? { ...Object.fromEntries(results), latencyMs }
192+
: undefined,
193+
seekResults: !skipSeek && seekResults
194+
? { runs: seekResults, stats: computeSeekStats(seekResults) }
195+
: undefined,
171196
downloadResults: !skipDownload ? { single: singleResult, multi: multiResult } : undefined,
172197
});
173198

174199
console.log(renderTable("Link Information", linkInfoRows(linkInfo)));
175200
if (!skipTimings) {
176-
console.log(renderTable("Network Timings", timingRows(results)));
201+
console.log(renderTable("Network Timings", timingRows(results, latencyMs)));
177202
}
178203
if (!skipSeek && seekResults) {
179204
console.log(
180205
renderMultiTable(
181206
"Seek Results",
182207
["#", "Status", "TTFB", "Receive", "Total"],
183-
seekRows(seekResults),
208+
[...seekRows(seekResults), ...seekStatsRows(seekResults)],
184209
),
185210
);
186211
}

src/functions/linkValidation.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@ export type LinkInformation = {
1111
error?: string;
1212
}
1313

14-
async function isVideoLink(headers: Headers): Promise<boolean> {
14+
const VIDEO_EXTENSIONS = new Set([
15+
"mp4", "mkv", "webm", "mov", "avi", "flv", "wmv",
16+
"m4v", "mpg", "mpeg", "ts", "m2ts", "3gp", "ogv", "vob",
17+
]);
18+
19+
async function isVideoLink(headers: Headers, fileName: string | null): Promise<boolean> {
1520
const contentType = headers.get("Content-Type");
16-
return contentType ? contentType.startsWith("video/") : false;
21+
if (contentType?.startsWith("video/")) return true;
22+
if (fileName) {
23+
const ext = fileName.split(".").pop()?.toLowerCase();
24+
if (ext && VIDEO_EXTENSIONS.has(ext)) return true;
25+
}
26+
return false;
1727
}
1828

1929
async function getLinkName(headers: Headers, link: string): Promise<string> {
@@ -113,14 +123,15 @@ export async function getLinkInformation(link: string): Promise<LinkInformation>
113123
};
114124
}
115125

126+
const fileName = await getLinkName(response.headers, link);
116127
const data: LinkInformation = {
117128
status: response.status,
118129
contentType: response.headers.get("Content-Type"),
119130
size: await linkSize(response.headers),
120131
acceptsRanges: await linkAcceptsRanges(response.headers),
121-
fileName: await getLinkName(response.headers, link),
132+
fileName,
122133
domain: new URL(link).hostname,
123-
isVideo: await isVideoLink(response.headers)
134+
isVideo: await isVideoLink(response.headers, fileName)
124135
}
125136
return data;
126137
} catch (error) {

src/library/tables.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export function formatDuration(ms: number): string {
2525
return `${(ms / 1000).toFixed(2)} s`;
2626
}
2727

28+
export function formatEta(seconds: number): string {
29+
if (!Number.isFinite(seconds) || seconds < 0) return "—";
30+
const s = Math.round(seconds);
31+
const h = Math.floor(s / 3600);
32+
const m = Math.floor((s % 3600) / 60);
33+
const sec = s % 60;
34+
const pad = (n: number) => n.toString().padStart(2, "0");
35+
if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
36+
return `${m}:${pad(sec)}`;
37+
}
38+
2839
export const PHASES: { key: TimingPhase; label: string }[] = [
2940
{ key: "dns", label: "DNS Resolution" },
3041
{ key: "tcp", label: "TCP Connect" },
@@ -46,23 +57,27 @@ export function renderTable(title: string, rows: [string, string][]): string {
4657
return [top, sep, ...body, bot].join("\n");
4758
}
4859

60+
export type TableRow = string[] | "separator";
61+
4962
export function renderMultiTable(
5063
title: string,
5164
headers: string[],
52-
rows: string[][],
65+
rows: TableRow[],
5366
): string {
67+
const dataRows = rows.filter((r): r is string[] => Array.isArray(r));
5468
const widths = headers.map((h, i) =>
55-
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
69+
Math.max(h.length, ...dataRows.map((r) => (r[i] ?? "").length)),
5670
);
5771
const inner = widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * 3 + 2;
5872
const titleBar = ` ${title} `;
5973
const top = `╭${titleBar}${"─".repeat(Math.max(0, inner - titleBar.length))}╮`;
6074
const colSep = `├${widths.map((w) => "─".repeat(w + 2)).join("┬")}┤`;
6175
const headerLine = `│ ${headers.map((h, i) => h.padEnd(widths[i]!)).join(" │ ")} │`;
6276
const headerSep = `├${widths.map((w) => "─".repeat(w + 2)).join("┼")}┤`;
63-
const bodyLines = rows.map(
64-
(r) => `│ ${r.map((c, i) => (c ?? "").padEnd(widths[i]!)).join(" │ ")} │`,
65-
);
77+
const bodyLines = rows.map((r) => {
78+
if (r === "separator") return headerSep;
79+
return `│ ${r.map((c, i) => (c ?? "").padEnd(widths[i]!)).join(" │ ")} │`;
80+
});
6681
const bot = `╰${widths.map((w) => "─".repeat(w + 2)).join("┴")}╯`;
6782
return [top, colSep, headerLine, headerSep, ...bodyLines, bot].join("\n");
6883
}
@@ -80,11 +95,18 @@ export function linkInfoRows(info: LinkInformation): [string, string][] {
8095
];
8196
}
8297

83-
export function timingRows(results: Map<TimingPhase, number>): [string, string][] {
84-
return PHASES.map(({ key, label }) => [
98+
export function timingRows(
99+
results: Map<TimingPhase, number>,
100+
latencyMs?: number | null,
101+
): [string, string][] {
102+
const rows: [string, string][] = PHASES.map(({ key, label }) => [
85103
label,
86104
results.has(key) ? `${results.get(key)!.toFixed(2)} ms` : "—",
87105
]);
106+
if (latencyMs !== undefined) {
107+
rows.push(["Latency", latencyMs !== null ? `${latencyMs.toFixed(2)} ms` : "—"]);
108+
}
109+
return rows;
88110
}
89111

90112
export function downloadRows(
@@ -114,3 +136,63 @@ export function seekRows(seeks: LinkTimings[]): string[][] {
114136
fmt(s.total),
115137
]);
116138
}
139+
140+
export interface SeekStat {
141+
avg: number;
142+
stdev: number;
143+
min: number;
144+
max: number;
145+
}
146+
147+
export interface SeekStats {
148+
ttfb: SeekStat | null;
149+
receive: SeekStat | null;
150+
total: SeekStat | null;
151+
}
152+
153+
function statsOf(values: number[]): SeekStat | null {
154+
if (values.length === 0) return null;
155+
const avg = values.reduce((a, b) => a + b, 0) / values.length;
156+
const stdev =
157+
values.length < 2
158+
? 0
159+
: Math.sqrt(
160+
values.reduce((a, b) => a + (b - avg) ** 2, 0) / (values.length - 1),
161+
);
162+
return { avg, stdev, min: Math.min(...values), max: Math.max(...values) };
163+
}
164+
165+
export function computeSeekStats(seeks: LinkTimings[]): SeekStats {
166+
const collect = (pick: (s: LinkTimings) => number | null) =>
167+
seeks.map(pick).filter((v): v is number => v !== null);
168+
return {
169+
ttfb: statsOf(collect((s) => s.wait)),
170+
receive: statsOf(collect((s) => s.receive)),
171+
total: statsOf(collect((s) => s.total)),
172+
};
173+
}
174+
175+
export function seekStatsRows(seeks: LinkTimings[]): TableRow[] {
176+
const fmt = (n: number | null | undefined) =>
177+
n === null || n === undefined ? "—" : `${n.toFixed(2)} ms`;
178+
const stats = computeSeekStats(seeks);
179+
const cols: Array<keyof SeekStat> = ["avg", "stdev", "min", "max"];
180+
const labels: Record<keyof SeekStat, string> = {
181+
avg: "Avg",
182+
stdev: "Stdev",
183+
min: "Min",
184+
max: "Max",
185+
};
186+
const rows: TableRow[] = [];
187+
for (const key of cols) {
188+
rows.push("separator");
189+
rows.push([
190+
labels[key],
191+
"",
192+
fmt(stats.ttfb?.[key]),
193+
fmt(stats.receive?.[key]),
194+
fmt(stats.total?.[key]),
195+
]);
196+
}
197+
return rows;
198+
}

0 commit comments

Comments
 (0)