-
Notifications
You must be signed in to change notification settings - Fork 80
Expand file tree
/
Copy pathformat.ts
More file actions
239 lines (217 loc) · 8.86 KB
/
Copy pathformat.ts
File metadata and controls
239 lines (217 loc) · 8.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
const STROOPS_PER_XLM = 10_000_000;
const DEFAULT_LOCALE = "en-US";
const formattersCache = new Map<string, Intl.NumberFormat>();
function getFormatter(locale: string, options: Intl.NumberFormatOptions): Intl.NumberFormat {
const key = `${locale}-${JSON.stringify(options)}`;
let formatter = formattersCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options);
formattersCache.set(key, formatter);
}
return formatter;
}
export interface FormatStroopsOptions {
/** If true, format as raw stroops instead of converting to XLM (e.g. "123,456,789 stroops") */
forceRaw?: boolean;
/** The locale to use for formatting. Defaults to "en-US" */
locale?: string;
}
/**
* Format a stroops amount using Stellar's 1 XLM = 10,000,000 stroops ratio.
* Zero remains `0 XLM`. Non-zero sub-cent values stay in grouped raw stroops
* so tiny balances are not hidden as `0.00 XLM`.
*
* @param stroops - The amount in stroops to format.
* @param optionsOrForceRaw - Optional configuration object or boolean toggle to force raw stroops formatting.
* @returns A locale-formatted string representation.
*/
export function formatStroops(
stroops: number,
optionsOrForceRaw?: FormatStroopsOptions | boolean
): string {
const forceRaw = typeof optionsOrForceRaw === "boolean"
? optionsOrForceRaw
: optionsOrForceRaw?.forceRaw;
let locale = DEFAULT_LOCALE;
if (optionsOrForceRaw && typeof optionsOrForceRaw === "object" && optionsOrForceRaw.locale) {
locale = optionsOrForceRaw.locale;
}
const xlm = stroops / STROOPS_PER_XLM;
// Keep the 0 XLM zero case
if (xlm === 0) return "0 XLM";
// Check if we force raw stroops or if we are below the sub-cent threshold (< 0.01 XLM in absolute terms)
if (forceRaw || Math.abs(xlm) < 0.01) {
const unit = Math.abs(stroops) === 1 ? "stroop" : "stroops";
const formatter = getFormatter(locale, { maximumFractionDigits: 0 });
return `${formatter.format(stroops)} ${unit}`;
}
// Otherwise, format as XLM with 2 to 7 decimal places
const formatter = getFormatter(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 7,
});
return `${formatter.format(xlm)} XLM`;
}
/**
* Format a numeric request count with thousands separators.
*
* @param n - The number of requests.
* @param optionsOrLocale - Optional configuration object or string locale.
* @returns A locale-formatted integer string.
*/
export function formatRequests(
n: number,
optionsOrLocale?: { locale?: string } | string
): string {
const locale = typeof optionsOrLocale === "string"
? optionsOrLocale
: optionsOrLocale?.locale || DEFAULT_LOCALE;
const formatter = getFormatter(locale, { maximumFractionDigits: 0 });
return formatter.format(n);
}
/** Format an absolute timestamp into a short HH:MM:SS string. */
export function formatTime(ms: number): string {
const d = new Date(ms);
return d.toISOString().slice(11, 19);
}
/**
* Maximum number of characters any single serialised payload is allowed to
* occupy in the event log before the renderer truncates it with a marker.
*
* Lower so a single event never dominates the page (DOM cost + scroll). Keep
* the value stable so callers / tests can rely on it.
*/
export const EVENT_PAYLOAD_MAX_CHARS = 5000;
/** Marker appended to truncated payloads so readers can spot the cut-off. */
export const EVENT_PAYLOAD_TRUNCATED_MARKER = "\n…(truncated)";
/**
* Maximum number of top-level rows rendered in a single pass on any list page.
*
* This acts as a client-side defence-in-depth cap: if the backend ignores the
* page/limit parameters and sends back a huge payload, the browser will only
* ever create this many DOM nodes per list. It is **not** a substitute for
* server-side pagination — the backend should still enforce its own limit so
* the response size stays reasonable over the wire.
*
* Chosen above the expected values for every list in the app:
* - events page: backend limit = 100
* - search page: backend limit = 50
* - top-agents page: backend limit = 25 per page
*
* Set to 100 so it covers the largest expected page without firing
* a false-positive truncation note in normal operation.
*/
export const MAX_RENDERED_ROWS = 100;
/**
* Safely serialise an arbitrary value to JSON, defending against:
* - circular references (replaced with `[Circular]`)
* - values JSON can't represent natively, e.g. `BigInt` (replaced with a
* stringified marker)
* Then truncate the result to at most `maxChars` characters and append a
* visible marker so the operator can see the cut-off.
*
* The function never throws so it can be used inside render code without
* needing an error boundary.
*/
export function safeStringify(
value: unknown,
maxChars: number = EVENT_PAYLOAD_MAX_CHARS
): string {
// Top-level `undefined` / functions / symbols: `JSON.stringify` returns
// `undefined` and never invokes the replacer consistently. Surface them
// as a sentinel so callers always get a renderable string.
if (
value === undefined ||
typeof value === "function" ||
typeof value === "symbol"
) {
return `[${typeof value}]`;
}
const seen = new WeakSet<object>();
let serialised = "";
try {
serialised = JSON.stringify(
value,
(_key, v) => {
if (typeof v === "bigint") return `[BigInt:${v.toString()}]`;
if (typeof v === "function") return "[Function]";
if (typeof v === "symbol") return "[Symbol]";
if (typeof v === "undefined") return "[undefined]";
if (v !== null && typeof v === "object") {
if (seen.has(v)) return "[Circular]";
seen.add(v);
}
return v;
},
2
);
} catch {
// Defensive: JSON.stringify should be total after the replacer above,
// but we still refuse to throw inside render code.
return "[unserialisable]";
}
if (serialised.length <= maxChars) return serialised;
return (
serialised.slice(0, Math.max(0, maxChars)) + EVENT_PAYLOAD_TRUNCATED_MARKER
);
}
/** Character used to mark the removed middle section of a truncated id. */
export const TRUNCATE_ELLIPSIS = "…";
/** Default number of leading characters kept by {@link truncateMiddle}. */
export const TRUNCATE_HEAD_DEFAULT = 8;
/** Default number of trailing characters kept by {@link truncateMiddle}. */
export const TRUNCATE_TAIL_DEFAULT = 6;
/**
* Truncate a long identifier by collapsing its middle into an ellipsis while
* preserving both ends, e.g. `GABCDEFG…XYZ123`. Agent and service ids only
* differ at the edges, so keeping the tail visible is what makes two truncated
* ids distinguishable — unlike CSS `text-overflow: ellipsis`, which hides it.
*
* The input is returned unchanged when it already fits the budget
* (`head + tail + 1` characters, the `1` being the ellipsis itself), so short
* ids never gain a marker. Counting is code-point aware so surrogate pairs are
* never split in half. Negative or fractional `head` / `tail` values are
* clamped to non-negative integers.
*
* Callers rendering the truncated form should expose the full value through
* `title` and an accessible label (e.g. `aria-label`) so hover and assistive
* technology both see the complete identifier.
*
* @param value - The identifier to truncate.
* @param head - Leading characters to keep. Defaults to {@link TRUNCATE_HEAD_DEFAULT}.
* @param tail - Trailing characters to keep. Defaults to {@link TRUNCATE_TAIL_DEFAULT}.
* @returns The original string, or `head` chars + `…` + `tail` chars.
*/
export function truncateMiddle(
value: string,
head: number = TRUNCATE_HEAD_DEFAULT,
tail: number = TRUNCATE_TAIL_DEFAULT
): string {
const safeHead = Number.isFinite(head) ? Math.max(0, Math.floor(head)) : TRUNCATE_HEAD_DEFAULT;
const safeTail = Number.isFinite(tail) ? Math.max(0, Math.floor(tail)) : TRUNCATE_TAIL_DEFAULT;
// Split into code points so astral characters (emoji, some CJK) are kept
// whole instead of being cut between surrogate halves.
const chars = Array.from(value);
const budget = safeHead + safeTail + TRUNCATE_ELLIPSIS.length;
if (chars.length <= budget) return value;
const headPart = chars.slice(0, safeHead).join("");
const tailPart = safeTail > 0 ? chars.slice(-safeTail).join("") : "";
return `${headPart}${TRUNCATE_ELLIPSIS}${tailPart}`;
}
type TimestampInput = number | string | null | undefined;
/**
* Format a timestamp that may be malformed into a safe ISO string. Non-finite
* numbers, nullish arguments, and unparseable values fall back to the
* placeholder so the page never throws `Invalid time value`.
*/
export function safeFormatTimestamp(
value: TimestampInput,
fallback: string = "\u2014"
): string {
if (value === null || value === undefined) return fallback;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
const d = new Date(n);
if (Number.isNaN(d.getTime())) return fallback;
return d.toISOString();
}