mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
201 lines
6.6 KiB
TypeScript
201 lines
6.6 KiB
TypeScript
/**
|
|
* Shared formatting utilities — DRY extraction from duplicated functions
|
|
* across RequestLoggerV2.js, UsageAnalytics.js, ProxyLogger.js
|
|
*
|
|
* Prevents copy-paste duplication and provides a single source of truth.
|
|
*/
|
|
|
|
import { maskEmail } from "./maskEmail";
|
|
|
|
/**
|
|
* Format an ISO date string to a localized time string (HH:MM:SS).
|
|
* @param {string} isoString - ISO 8601 date string
|
|
* @returns {string}
|
|
*/
|
|
export function formatTime(isoString: string | null | undefined) {
|
|
try {
|
|
if (!isoString) return "-";
|
|
const d = new Date(isoString);
|
|
return d.toLocaleTimeString("en-US", {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
} catch {
|
|
return "-";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a duration in milliseconds to a human-readable string.
|
|
* @param {number} ms - Duration in milliseconds
|
|
* @returns {string} e.g., "42ms", "1.2s", "-"
|
|
*/
|
|
export function formatDuration(ms: number | null | undefined) {
|
|
if (!ms) return "-";
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
/**
|
|
* Mask a string by showing only start and end characters.
|
|
* @param {string} value - Value to mask
|
|
* @param {number} start - Number of characters to show at start (default: 2)
|
|
* @param {number} end - Number of characters to show at end (default: 2)
|
|
* @returns {string}
|
|
*/
|
|
export function maskSegment(value: string | null | undefined, start = 2, end = 2) {
|
|
if (!value) return "";
|
|
if (value.length <= start + end) return `${value.slice(0, 1)}***`;
|
|
return `${value.slice(0, start)}***${value.slice(-end)}`;
|
|
}
|
|
|
|
/**
|
|
* Mask an email or account string for display.
|
|
* @param {string} account - Account identifier (email or username)
|
|
* @param {boolean} emailsVisible - Whether to show full email (true) or mask it (false)
|
|
* @returns {string}
|
|
*/
|
|
export function maskAccount(account: string | null | undefined, emailsVisible: boolean) {
|
|
if (!account || account === "-") return "-";
|
|
if (emailsVisible) return account;
|
|
const atIdx = account.indexOf("@");
|
|
if (atIdx > 3) {
|
|
return maskEmail(account);
|
|
}
|
|
if (account.length > 8) {
|
|
return account.slice(0, 5) + "***";
|
|
}
|
|
return account;
|
|
}
|
|
|
|
export function stableAccountSuffix(account: string | null | undefined): string {
|
|
if (!account || account === "-") return "0000";
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < account.length; i++) {
|
|
hash ^= account.charCodeAt(i);
|
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
}
|
|
return hash.toString(16).padStart(8, "0").slice(0, 4);
|
|
}
|
|
|
|
/**
|
|
* Format an API key label, showing full name but masking the ID.
|
|
* @param {string} apiKeyName - Human-readable name of the key
|
|
* @param {string} apiKeyId - Unique ID of the key
|
|
* @returns {string}
|
|
*/
|
|
export function formatApiKeyLabel(
|
|
apiKeyName: string | null | undefined,
|
|
apiKeyId: string | null | undefined
|
|
) {
|
|
if (!apiKeyName && !apiKeyId) return "—";
|
|
const displayName = apiKeyName || "key";
|
|
if (!apiKeyId) return displayName;
|
|
return `${displayName} (${maskSegment(apiKeyId, 4, 4)})`;
|
|
}
|
|
|
|
/**
|
|
* Format large numbers with K/M/B suffixes.
|
|
* @param {number} n - Number to format
|
|
* @returns {string}
|
|
*/
|
|
export function fmtCompact(n: number | null | undefined) {
|
|
if (n && n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
if (n && n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n && n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return new Intl.NumberFormat().format(n || 0);
|
|
}
|
|
|
|
/**
|
|
* Format a number with full locale formatting.
|
|
* @param {number} n - Number to format
|
|
* @returns {string}
|
|
*/
|
|
export function fmtFull(n: number | null | undefined) {
|
|
return new Intl.NumberFormat().format(n || 0);
|
|
}
|
|
|
|
/**
|
|
* Format a USD cost for display.
|
|
* Sub-cent values show additional precision.
|
|
* @param {number} usd - Cost in USD
|
|
* @returns {string}
|
|
*/
|
|
export function formatCost(usd: number | null | undefined): string {
|
|
const value = Number(usd || 0);
|
|
if (!Number.isFinite(value) || value === 0) return "$0.00";
|
|
if (value < 0.01) return `$${value.toFixed(6)}`;
|
|
if (value < 1) return `$${value.toFixed(4)}`;
|
|
return `$${value.toFixed(2)}`;
|
|
}
|
|
|
|
export const fmtCost = formatCost;
|
|
|
|
/**
|
|
* Truncate a URL for compact display.
|
|
* @param {string} url - Full URL
|
|
* @param {number} max - Maximum characters (default: 50)
|
|
* @returns {string}
|
|
*/
|
|
export function truncateUrl(url: string | null | undefined, max = 50) {
|
|
if (!url) return "-";
|
|
try {
|
|
const parsed = new URL(url);
|
|
const display = parsed.hostname + parsed.pathname;
|
|
return display.length > max ? display.slice(0, max) + "…" : display;
|
|
} catch {
|
|
return url.length > max ? url.slice(0, max) + "…" : url;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely extract a finite number, returning undefined for invalid values.
|
|
* Used by quota normalization in both backend (quotaCache) and frontend (ProviderLimits).
|
|
*/
|
|
export function safePercentage(value: unknown): number | undefined {
|
|
return typeof value === "number" && isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s".
|
|
* Returns null if resetAt is in the past or not set.
|
|
*
|
|
* Lives here (client-safe utils) — not in db/providers/rateLimit — so client
|
|
* components can render a cooldown countdown without dragging the server-only
|
|
* DB barrel (better-sqlite3/ioredis → node:net) into the browser bundle.
|
|
* `rateLimit.ts` re-exports this for its server callers.
|
|
*/
|
|
export function formatResetCountdown(resetAt: string | number | null | undefined): string | null {
|
|
if (!resetAt) return null;
|
|
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
|
if (isNaN(resetTime)) return null;
|
|
const diffMs = resetTime - Date.now();
|
|
if (diffMs <= 0) return null;
|
|
const totalSeconds = Math.floor(diffMs / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
/**
|
|
* Coerces a persisted log `error` field into safe display text. It is
|
|
* legitimate for this field to be a structured object (e.g. `{ code,
|
|
* message }`) — only the RENDER needs to be a string, never the persisted
|
|
* artifact itself. Used by RequestLoggerDetail to avoid React error #31
|
|
* ("Objects are not valid as a React child") when rendering it. See #7845.
|
|
*/
|
|
export function formatErrorForDisplay(err: unknown): string | null {
|
|
if (err == null) return null;
|
|
if (typeof err === "string") return err;
|
|
try {
|
|
return JSON.stringify(err, null, 2);
|
|
} catch {
|
|
return String(err);
|
|
}
|
|
}
|