first commit
This commit is contained in:
15
ui/lib/utils/array.ts
Normal file
15
ui/lib/utils/array.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const parseArrayFromText = (text?: string): string[] => {
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
};
|
||||
|
||||
export const isArrayEqual = <T>(array1: T[], array2: T[]): boolean => {
|
||||
return array1?.length === array2?.length && array1?.every((value, index) => value === array2[index]);
|
||||
};
|
||||
|
||||
export const isArrayOverlapping = <T>(array1: T[], array2: T[]): boolean => {
|
||||
return array1?.some((value) => array2.includes(value));
|
||||
};
|
||||
32
ui/lib/utils/browser-download.ts
Normal file
32
ui/lib/utils/browser-download.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const safeStringify = (value: unknown, space: number): string => {
|
||||
try {
|
||||
return JSON.stringify(value, null, space);
|
||||
} catch {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString();
|
||||
if (typeof val === "object" && val !== null) {
|
||||
if (seen.has(val)) return "[Circular]";
|
||||
seen.add(val);
|
||||
}
|
||||
return val;
|
||||
},
|
||||
space
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadAsJson = (data: unknown, filename: string) => {
|
||||
const json = safeStringify(data, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
371
ui/lib/utils/celConverterRouting.ts
Normal file
371
ui/lib/utils/celConverterRouting.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* CEL Converter for Routing Rules
|
||||
* Converts react-querybuilder rules to CEL expressions
|
||||
*/
|
||||
|
||||
import { RuleGroupType, RuleType } from "react-querybuilder";
|
||||
import { getOperatorCELSyntax } from "@/lib/config/celOperatorsRouting";
|
||||
|
||||
/**
|
||||
* RE2-incompatible constructs (not supported by CEL/RE2).
|
||||
* Used for syntactic checks so patterns validated here work in CEL regex functions.
|
||||
*/
|
||||
const RE2_UNSUPPORTED = {
|
||||
lookbehindPositive: "(?<=",
|
||||
lookbehindNegative: "(?<!",
|
||||
lookaheadPositive: "(?=",
|
||||
lookaheadNegative: "(?!",
|
||||
} as const;
|
||||
|
||||
/** Matches numeric backreferences (\1, \2, ... \9, \10, etc.) */
|
||||
const RE2_BACKREF = /\\[0-9]+/;
|
||||
|
||||
/**
|
||||
* Validate regex pattern - checks that it is valid and RE2-compatible (for CEL).
|
||||
* RE2 does not support lookarounds or backreferences. Returns null if valid,
|
||||
* error message if invalid or RE2-incompatible.
|
||||
*/
|
||||
export function validateRegexPattern(pattern: string): string | null {
|
||||
if (!pattern || typeof pattern !== "string") {
|
||||
return null; // Empty patterns are valid
|
||||
}
|
||||
|
||||
// Reject RE2-unsupported constructs
|
||||
if (pattern.includes(RE2_UNSUPPORTED.lookbehindPositive)) {
|
||||
return "RE2 incompatible: positive lookbehind (?<=...) is not supported";
|
||||
}
|
||||
if (pattern.includes(RE2_UNSUPPORTED.lookbehindNegative)) {
|
||||
return "RE2 incompatible: negative lookbehind (?<!...) is not supported";
|
||||
}
|
||||
if (pattern.includes(RE2_UNSUPPORTED.lookaheadPositive)) {
|
||||
return "RE2 incompatible: positive lookahead (?=...) is not supported";
|
||||
}
|
||||
if (pattern.includes(RE2_UNSUPPORTED.lookaheadNegative)) {
|
||||
return "RE2 incompatible: negative lookahead (?!...) is not supported";
|
||||
}
|
||||
if (RE2_BACKREF.test(pattern)) {
|
||||
return "RE2 incompatible: numeric backreferences (e.g. \\1, \\2) are not supported";
|
||||
}
|
||||
|
||||
// Basic syntax check via JS RegExp (catches invalid escaping, etc.)
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
return null; // Valid regex
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Invalid regex pattern";
|
||||
return `Invalid regex: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse keyValue pair from string format "key:value"
|
||||
*/
|
||||
function parseKeyValue(value: string): { key: string; value: string } | null {
|
||||
if (!value || typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try parsing as JSON array first (for comma-separated values)
|
||||
if (value.startsWith("[")) {
|
||||
return null; // This is an array, not a key-value pair
|
||||
}
|
||||
|
||||
// Handle "key" format for existence checks
|
||||
const colonIndex = value.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
return {
|
||||
key: value.substring(0, colonIndex).trim(),
|
||||
value: value.substring(colonIndex + 1).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// If no colon, treat entire string as key (for existence checks)
|
||||
return {
|
||||
key: value.trim(),
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in strings
|
||||
*/
|
||||
function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value based on operator type
|
||||
*/
|
||||
function formatValue(value: any, operator: string): string {
|
||||
// Handle array values for 'in' and 'notIn' operators
|
||||
if (operator === "in" || operator === "notIn") {
|
||||
let arrayValue: string[];
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
// Try parsing as JSON array
|
||||
arrayValue = JSON.parse(value);
|
||||
if (!Array.isArray(arrayValue)) {
|
||||
arrayValue = [String(value)];
|
||||
}
|
||||
} catch {
|
||||
// Split by comma if not JSON
|
||||
arrayValue = value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v);
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
arrayValue = value;
|
||||
} else {
|
||||
arrayValue = [String(value)];
|
||||
}
|
||||
|
||||
const formattedValues = arrayValue.map((v) => `"${escapeString(String(v))}"`);
|
||||
return `[${formattedValues.join(", ")}]`;
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Handle booleans
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
// Handle string values (regex patterns for matches operator)
|
||||
if (operator === "matches") {
|
||||
// For regex, wrap in forward slashes (CEL format) or quotes
|
||||
return `"${escapeString(String(value))}"`;
|
||||
}
|
||||
|
||||
// Default: treat as string
|
||||
return `"${escapeString(String(value))}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single rule to CEL expression
|
||||
*/
|
||||
function convertRuleToCEL(rule: RuleType): string {
|
||||
const { field, operator, value } = rule;
|
||||
|
||||
if (!field || !operator) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const celOperator = getOperatorCELSyntax(operator);
|
||||
|
||||
// Handle existence checks (null/notNull)
|
||||
// Key-value path (headers/params) checks key presence in the map.
|
||||
// For all other fields (provider, model, etc.) fall through to the simple equality check.
|
||||
const isKeyValueField = field === "headers" || field === "params";
|
||||
|
||||
if (operator === "null") {
|
||||
if (isKeyValueField) {
|
||||
const keyValuePair = parseKeyValue(String(value));
|
||||
if (keyValuePair && keyValuePair.key) {
|
||||
return `!(${formatValue(keyValuePair.key, "text")} in ${field})`;
|
||||
}
|
||||
}
|
||||
// has() requires a field selection (e.g. has(obj.field)) and cannot be used with bare
|
||||
// variable names. Plain string variables are always defined; "not set" means empty string.
|
||||
return `${field} == ""`;
|
||||
}
|
||||
|
||||
if (operator === "notNull") {
|
||||
if (isKeyValueField) {
|
||||
const keyValuePair = parseKeyValue(String(value));
|
||||
if (keyValuePair && keyValuePair.key) {
|
||||
return `${formatValue(keyValuePair.key, "text")} in ${field}`;
|
||||
}
|
||||
}
|
||||
return `${field} != ""`;
|
||||
}
|
||||
|
||||
// Handle string method operators (startsWith, endsWith, contains, matches)
|
||||
const stringMethods = ["startsWith", "endsWith", "contains", "matches"];
|
||||
if (stringMethods.includes(celOperator)) {
|
||||
const formattedValue = formatValue(value, operator);
|
||||
|
||||
// Handle keyValue fields (headers, params)
|
||||
if (isKeyValueField) {
|
||||
const keyValuePair = parseKeyValue(String(value));
|
||||
if (keyValuePair && keyValuePair.key && keyValuePair.value) {
|
||||
const fieldPath = `${field}[${formatValue(keyValuePair.key, "text")}]`;
|
||||
const actualValue = formatValue(keyValuePair.value, operator);
|
||||
return `${fieldPath}.${celOperator}(${actualValue})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular field handling
|
||||
return `${field}.${celOperator}(${formattedValue})`;
|
||||
}
|
||||
|
||||
// Handle tokens_used, request, and budget_used
|
||||
// Structure: tokens_used > 80.0 or request >= 75.0 or budget_used > 50.0
|
||||
// These are simple numeric comparisons against percent_used values from GetBudgetAndRateLimitStatus
|
||||
// which already returns the max of model+provider, model-only, and provider-only configs
|
||||
const isRateLimitOrBudgetField = field === "tokens_used" || field === "request" || field === "budget_used";
|
||||
if (isRateLimitOrBudgetField) {
|
||||
const thresholdValue = String(value).trim();
|
||||
if (thresholdValue) {
|
||||
// Convert to double to match CEL variable type (tokens_used, request, budget_used are all doubles)
|
||||
const numValue = parseFloat(thresholdValue);
|
||||
let actualValue: string;
|
||||
if (!isNaN(numValue)) {
|
||||
// Format as double with decimal point
|
||||
actualValue = Number.isInteger(numValue) ? `${numValue}.0` : numValue.toString();
|
||||
} else {
|
||||
actualValue = thresholdValue;
|
||||
}
|
||||
return `${field} ${celOperator} ${actualValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other keyValue fields (headers, params) for other operators
|
||||
if (isKeyValueField) {
|
||||
const keyValuePair = parseKeyValue(String(value));
|
||||
if (keyValuePair && keyValuePair.key && keyValuePair.value) {
|
||||
const fieldPath = `${field}[${formatValue(keyValuePair.key, "text")}]`;
|
||||
const actualValue = formatValue(keyValuePair.value, operator);
|
||||
|
||||
// For 'notIn' operator, wrap with negation since CEL has no "not in" infix operator
|
||||
if (operator === "notIn") {
|
||||
return `!(${fieldPath} in ${actualValue})`;
|
||||
}
|
||||
|
||||
// For 'in' operator and others, use standard binary syntax
|
||||
return `${fieldPath} ${celOperator} ${actualValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular field handling for binary operators
|
||||
const formattedValue = formatValue(value, operator);
|
||||
|
||||
// For 'notIn' operator, wrap with negation since CEL has no "not in" infix operator
|
||||
if (operator === "notIn") {
|
||||
return `!(${field} in ${formattedValue})`;
|
||||
}
|
||||
|
||||
return `${field} ${celOperator} ${formattedValue}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rule group (possibly nested) to CEL expression
|
||||
*/
|
||||
export function convertRuleGroupToCEL(ruleGroup: RuleGroupType | undefined): string {
|
||||
if (!ruleGroup || !ruleGroup.rules || ruleGroup.rules.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const combinator = ruleGroup.combinator === "or" ? "||" : "&&";
|
||||
const expressions: string[] = [];
|
||||
|
||||
for (const rule of ruleGroup.rules) {
|
||||
if ("rules" in rule) {
|
||||
// It's a nested group
|
||||
const nestedExpression = convertRuleGroupToCEL(rule as RuleGroupType);
|
||||
if (nestedExpression) {
|
||||
expressions.push(`(${nestedExpression})`);
|
||||
}
|
||||
} else {
|
||||
// It's a rule
|
||||
const ruleExpression = convertRuleToCEL(rule as RuleType);
|
||||
if (ruleExpression) {
|
||||
expressions.push(ruleExpression);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expressions.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (expressions.length === 1) {
|
||||
return expressions[0];
|
||||
}
|
||||
|
||||
return expressions.join(` ${combinator} `);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate routing rules for regex pattern errors
|
||||
* Returns array of error messages, empty if valid
|
||||
*/
|
||||
export function validateRoutingRules(ruleGroup: RuleGroupType | undefined): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!ruleGroup || !ruleGroup.rules) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const validateRule = (rule: RuleType | RuleGroupType) => {
|
||||
if ("rules" in rule) {
|
||||
// Nested group - recursively validate
|
||||
for (const nestedRule of rule.rules) {
|
||||
validateRule(nestedRule);
|
||||
}
|
||||
} else {
|
||||
// Regular rule - check if it uses matches operator
|
||||
if (rule.operator === "matches" && rule.value) {
|
||||
const regexError = validateRegexPattern(String(rule.value));
|
||||
if (regexError) {
|
||||
errors.push(`Field "${rule.field}": ${regexError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const rule of ruleGroup.rules) {
|
||||
validateRule(rule);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that rules using rate limits or budgets have a model or provider condition
|
||||
* Returns array of error messages, empty if valid
|
||||
*/
|
||||
export function validateRateLimitAndBudgetRules(ruleGroup: RuleGroupType | undefined): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!ruleGroup || !ruleGroup.rules) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check if rule uses rate limits or budgets
|
||||
const hasRateLimitOrBudget = (rule: RuleType | RuleGroupType): boolean => {
|
||||
if ("rules" in rule) {
|
||||
// Nested group
|
||||
return rule.rules.some((r) => hasRateLimitOrBudget(r));
|
||||
}
|
||||
// Regular rule - check if field is rate limit or budget
|
||||
return (
|
||||
(rule as RuleType).field === "tokens_used" || (rule as RuleType).field === "request" || (rule as RuleType).field === "budget_used"
|
||||
);
|
||||
};
|
||||
|
||||
// Check if rule has model or provider condition
|
||||
const hasModelOrProviderCondition = (rule: RuleType | RuleGroupType): boolean => {
|
||||
if ("rules" in rule) {
|
||||
// Nested group - check all nested rules
|
||||
return rule.rules.some((r) => hasModelOrProviderCondition(r));
|
||||
}
|
||||
// Regular rule - check if field is model or provider
|
||||
return (rule as RuleType).field === "model" || (rule as RuleType).field === "provider";
|
||||
};
|
||||
|
||||
const ruleHasRateLimitOrBudget = ruleGroup.rules.some((r) => hasRateLimitOrBudget(r));
|
||||
|
||||
if (ruleHasRateLimitOrBudget) {
|
||||
const hasCondition = hasModelOrProviderCondition(ruleGroup);
|
||||
if (!hasCondition) {
|
||||
errors.push('Rules using rate limits or budget must have a "model" or "provider" condition');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
42
ui/lib/utils/csv.ts
Normal file
42
ui/lib/utils/csv.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Reusable CSV export utilities.
|
||||
*
|
||||
* Usage:
|
||||
* const csv = buildCSV(headers, rows);
|
||||
* downloadCSV(csv, "my-export");
|
||||
*/
|
||||
|
||||
/** Escape a cell value for CSV (RFC 4180). */
|
||||
function escapeCell(value: unknown): string {
|
||||
const str = String(value ?? "");
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CSV string from headers and rows.
|
||||
*
|
||||
* Each row is an array of cell values (string | number | boolean | null | undefined).
|
||||
*/
|
||||
export function buildCSV(headers: string[], rows: unknown[][]): string {
|
||||
return [headers, ...rows].map((row) => row.map(escapeCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of a CSV string.
|
||||
*
|
||||
* @param content The CSV string content
|
||||
* @param filename Base filename without extension (date suffix is appended automatically)
|
||||
*/
|
||||
export function downloadCSV(content: string, filename: string): void {
|
||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const now = new Date();
|
||||
const dateStamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
link.download = `${filename}-${dateStamp}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
33
ui/lib/utils/date.ts
Normal file
33
ui/lib/utils/date.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Converts a Date object to an RFC 3339 string with the local time zone offset.
|
||||
*
|
||||
* Example: 2025-11-19T12:23:19.421+05:30
|
||||
*
|
||||
* @param dateObj The Date object to convert (defaults to new Date() if null/undefined).
|
||||
* @returns The RFC 3339 formatted string with local offset.
|
||||
*/
|
||||
export function dateToRfc3339Local(dateObj?: Date): string {
|
||||
const now = dateObj instanceof Date ? dateObj : new Date();
|
||||
|
||||
// Helper function to pad single digits with a leading zero
|
||||
const pad = (num: number): string => (num < 10 ? "0" + num : String(num));
|
||||
|
||||
const Y = now.getFullYear();
|
||||
const M = pad(now.getMonth() + 1); // Month is 0-indexed (Jan=0)
|
||||
const D = pad(now.getDate());
|
||||
const H = pad(now.getHours());
|
||||
const m = pad(now.getMinutes());
|
||||
const S = pad(now.getSeconds());
|
||||
const ms = String(now.getMilliseconds()).padStart(3, "0");
|
||||
|
||||
// getTimezoneOffset() returns the difference in minutes from UTC for the local time.
|
||||
// The result is positive for time zones west of Greenwich and negative for those east.
|
||||
// We negate it to get the standard ISO/RFC sign convention (+ for East, - for West).
|
||||
const timezoneOffsetMinutes = -now.getTimezoneOffset();
|
||||
const sign = timezoneOffsetMinutes >= 0 ? "+" : "-";
|
||||
const absoluteOffset = Math.abs(timezoneOffsetMinutes);
|
||||
const offsetHours = pad(Math.floor(absoluteOffset / 60));
|
||||
const offsetMinutes = pad(absoluteOffset % 60);
|
||||
const rfc3339Local = `${Y}-${M}-${D}T${H}:${m}:${S}.${ms}${sign}${offsetHours}:${offsetMinutes}`;
|
||||
return rfc3339Local;
|
||||
}
|
||||
98
ui/lib/utils/governance.ts
Normal file
98
ui/lib/utils/governance.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Parses a duration string (e.g., "1m", "5m", "1h", "1d", "1w", "1M") into human readable format
|
||||
*/
|
||||
export function parseResetPeriod(duration: string): string {
|
||||
if (!duration) return "Unknown";
|
||||
|
||||
const timeValue = parseInt(duration.slice(0, -1));
|
||||
const timeUnit = duration.slice(-1);
|
||||
|
||||
const unitMap: Record<string, { singular: string; plural: string }> = {
|
||||
s: { singular: "second", plural: "seconds" },
|
||||
m: { singular: "minute", plural: "minutes" },
|
||||
h: { singular: "hour", plural: "hours" },
|
||||
d: { singular: "day", plural: "days" },
|
||||
w: { singular: "week", plural: "weeks" },
|
||||
M: { singular: "month", plural: "months" },
|
||||
y: { singular: "year", plural: "years" },
|
||||
};
|
||||
|
||||
const unit = unitMap[timeUnit];
|
||||
if (!unit) return duration;
|
||||
|
||||
const unitName = timeValue === 1 ? unit.singular : unit.plural;
|
||||
return `${timeValue} ${unitName}`;
|
||||
}
|
||||
|
||||
export function formatCurrency(dollars: number) {
|
||||
return `$${dollars.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number compactly (e.g. 10000 → "10K", 1500000 → "1.5M").
|
||||
* Uses Intl.NumberFormat so boundary values promote correctly (999,950 → "1M", not "1000K")
|
||||
* and trailing zeros are dropped (10,000 → "10K", not "10.0K").
|
||||
*/
|
||||
const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatCompactNumber(n: number): string {
|
||||
if (Math.abs(n) >= 1_000) return compactNumberFormatter.format(n);
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const shortDurationLabels: Record<string, string> = {
|
||||
"1m": "/min",
|
||||
"5m": "/5min",
|
||||
"15m": "/15min",
|
||||
"30m": "/30min",
|
||||
"1h": "/hr",
|
||||
"6h": "/6hr",
|
||||
"1d": "/day",
|
||||
"1w": "/wk",
|
||||
"1M": "/mo",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats rate limit into compact display lines.
|
||||
* e.g. ["10K tokens/hr", "100 req/hr"]
|
||||
*/
|
||||
export function formatRateLimitLines(rateLimits: {
|
||||
token_max_limit?: number | null;
|
||||
token_reset_duration?: string | null;
|
||||
request_max_limit?: number | null;
|
||||
request_reset_duration?: string | null;
|
||||
} | null | undefined): string[] {
|
||||
if (!rateLimits) return [];
|
||||
const lines: string[] = [];
|
||||
if (rateLimits.token_max_limit != null) {
|
||||
const duration = rateLimits.token_reset_duration ?? "";
|
||||
const suffix = shortDurationLabels[duration] ?? (duration ? `/${duration}` : "");
|
||||
lines.push(`${formatCompactNumber(rateLimits.token_max_limit)} tokens${suffix}`);
|
||||
}
|
||||
if (rateLimits.request_max_limit != null) {
|
||||
const duration = rateLimits.request_reset_duration ?? "";
|
||||
const suffix = shortDurationLabels[duration] ?? (duration ? `/${duration}` : "");
|
||||
lines.push(`${formatCompactNumber(rateLimits.request_max_limit)} req${suffix}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates usage percentage for rate limits
|
||||
*/
|
||||
export function calculateUsagePercentage(current: number, max: number): number {
|
||||
if (max === 0) return 0;
|
||||
return Math.round((current / max) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate variant for usage percentage badges
|
||||
*/
|
||||
export function getUsageVariant(percentage: number): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (percentage >= 90) return "destructive";
|
||||
if (percentage >= 75) return "secondary";
|
||||
return "default";
|
||||
}
|
||||
13
ui/lib/utils/numbers.ts
Normal file
13
ui/lib/utils/numbers.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const COMPACT_NUMBER_FORMAT = {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 2,
|
||||
} as const;
|
||||
|
||||
export function formatCompactNumber(value: number, maximumFractionDigits = 2): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
...COMPACT_NUMBER_FORMAT,
|
||||
maximumFractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
179
ui/lib/utils/pdf.ts
Normal file
179
ui/lib/utils/pdf.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Reusable PDF export utility.
|
||||
*
|
||||
* Captures an array of DOM sections as images via html2canvas and composes
|
||||
* them into a multi-page A4 PDF with jsPDF. Libraries are dynamically
|
||||
* imported so they only load when actually needed.
|
||||
*
|
||||
* Usage:
|
||||
* await generatePdf(
|
||||
* [{ element: el, label: "Overview" }, ...],
|
||||
* "dashboard-export",
|
||||
* );
|
||||
*/
|
||||
|
||||
export interface PdfSection {
|
||||
/** DOM element to capture */
|
||||
element: HTMLElement;
|
||||
/** Optional heading printed above the section in the PDF */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface PdfBranding {
|
||||
/** Path to logo image (relative to public dir, e.g. "/bifrost-logo.webp") */
|
||||
logoSrc: string;
|
||||
/** Text shown next to the logo */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
/** Canvas scale factor (default 1.5) */
|
||||
scale?: number;
|
||||
/** JPEG quality 0-1 (default 0.92) */
|
||||
quality?: number;
|
||||
/** Page margin in mm (default 10) */
|
||||
margin?: number;
|
||||
/** Page orientation (default "portrait") */
|
||||
orientation?: "portrait" | "landscape";
|
||||
/** Branding shown at the bottom-right of every page */
|
||||
branding?: PdfBranding;
|
||||
}
|
||||
|
||||
/** Load an image and return its data URL + natural dimensions. */
|
||||
async function loadImage(src: string): Promise<{ dataUrl: string; width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(img, 0, 0);
|
||||
resolve({
|
||||
dataUrl: canvas.toDataURL("image/png"),
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export async function generatePdf(sections: PdfSection[], filename: string, options: PdfOptions = {}): Promise<void> {
|
||||
const { scale = 1.5, quality = 0.92, margin = 10, orientation = "portrait", branding } = options;
|
||||
|
||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([import("html2canvas-pro"), import("jspdf")]);
|
||||
|
||||
// Pre-load branding logo if configured
|
||||
let logoData: { dataUrl: string; width: number; height: number } | null = null;
|
||||
if (branding?.logoSrc) {
|
||||
try {
|
||||
logoData = await loadImage(branding.logoSrc);
|
||||
} catch {
|
||||
// Logo failed to load — continue without it
|
||||
}
|
||||
}
|
||||
|
||||
const pdf = new jsPDF({ orientation, unit: "mm", format: "a4" });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
let cursorY = margin;
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const { element, label } = sections[i];
|
||||
|
||||
// Yield between sections so the UI stays responsive
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
scale,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
const imgHeight = (canvas.height * contentWidth) / canvas.width;
|
||||
const headingHeight = label ? 10 : 0;
|
||||
|
||||
// Start a new page if the heading + a meaningful chunk won't fit
|
||||
if (cursorY + headingHeight + 20 > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
cursorY = margin;
|
||||
}
|
||||
|
||||
if (label) {
|
||||
pdf.setFontSize(14);
|
||||
pdf.setTextColor(30, 30, 30);
|
||||
pdf.text(label, margin, cursorY + 5);
|
||||
cursorY += headingHeight;
|
||||
}
|
||||
|
||||
// Slice the captured image into page-sized chunks
|
||||
let yOffset = 0;
|
||||
while (yOffset < imgHeight) {
|
||||
const remainingOnPage = pageHeight - cursorY - margin;
|
||||
const sliceHeight = Math.min(remainingOnPage, imgHeight - yOffset);
|
||||
|
||||
const sourceY = (yOffset / imgHeight) * canvas.height;
|
||||
const sourceH = (sliceHeight / imgHeight) * canvas.height;
|
||||
|
||||
const sliceCanvas = document.createElement("canvas");
|
||||
sliceCanvas.width = canvas.width;
|
||||
sliceCanvas.height = Math.round(sourceH);
|
||||
const ctx = sliceCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceH, 0, 0, canvas.width, Math.round(sourceH));
|
||||
const sliceImg = sliceCanvas.toDataURL("image/jpeg", quality);
|
||||
pdf.addImage(sliceImg, "JPEG", margin, cursorY, contentWidth, sliceHeight);
|
||||
}
|
||||
|
||||
cursorY += sliceHeight;
|
||||
yOffset += sliceHeight;
|
||||
|
||||
if (yOffset < imgHeight) {
|
||||
pdf.addPage();
|
||||
cursorY = margin;
|
||||
}
|
||||
}
|
||||
|
||||
// Small gap between sections
|
||||
cursorY += 4;
|
||||
}
|
||||
|
||||
// Stamp branding on every page
|
||||
if (branding && (logoData || branding.text)) {
|
||||
const totalPages = pdf.getNumberOfPages();
|
||||
const brandingText = branding.text ?? "";
|
||||
const logoH = 3.5; // logo height in mm
|
||||
const logoW = logoData ? (logoData.width / logoData.height) * logoH : 0;
|
||||
const gap = logoData && brandingText ? 1.5 : 0;
|
||||
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(150, 150, 150);
|
||||
const textW = brandingText ? pdf.getTextWidth(brandingText) : 0;
|
||||
const totalW = textW + gap + logoW;
|
||||
|
||||
for (let p = 1; p <= totalPages; p++) {
|
||||
pdf.setPage(p);
|
||||
const x = pageWidth - margin - totalW;
|
||||
const y = pageHeight - margin + 2;
|
||||
|
||||
if (brandingText) {
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(150, 150, 150);
|
||||
pdf.text(brandingText, x, y + logoH / 2 + 1);
|
||||
}
|
||||
|
||||
if (logoData) {
|
||||
pdf.addImage(logoData.dataUrl, "PNG", x + textW + gap, y, logoW, logoH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
pdf.save(`${filename}-${dateStamp}.pdf`);
|
||||
}
|
||||
127
ui/lib/utils/port.ts
Normal file
127
ui/lib/utils/port.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Port and URL utility - single source of truth for Bifrost backend connectivity
|
||||
*
|
||||
* This utility handles:
|
||||
* - Development vs Production environment detection
|
||||
* - Dynamic port resolution
|
||||
* - URL generation for API calls and WebSocket connections
|
||||
* - Automatic protocol detection (http/https, ws/wss)
|
||||
*/
|
||||
|
||||
interface PortConfig {
|
||||
port: string;
|
||||
isDevelopment: boolean;
|
||||
baseUrl: string;
|
||||
wsUrl: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current port configuration based on environment
|
||||
*/
|
||||
function getPortConfig(): PortConfig {
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
if (isDevelopment) {
|
||||
// Development mode: Vite dev server runs on different port than Go server
|
||||
const port = process.env.BIFROST_PORT || "8080";
|
||||
return {
|
||||
port,
|
||||
isDevelopment: true,
|
||||
baseUrl: `http://localhost:${port}`,
|
||||
wsUrl: `ws://localhost:${port}`,
|
||||
host: `localhost:${port}`,
|
||||
};
|
||||
} else {
|
||||
// Production mode: UI is served by the same Go server
|
||||
// Use current window location for automatic port detection
|
||||
if (typeof window !== "undefined") {
|
||||
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
return {
|
||||
port: window.location.port || (window.location.protocol === "https:" ? "443" : "80"),
|
||||
isDevelopment: false,
|
||||
baseUrl: `${protocol}//${window.location.host}`,
|
||||
wsUrl: `${wsProtocol}//${window.location.host}`,
|
||||
host: window.location.host,
|
||||
};
|
||||
} else {
|
||||
// Server-side rendering fallback - use relative URLs
|
||||
return {
|
||||
port: "unknown",
|
||||
isDevelopment: false,
|
||||
baseUrl: "",
|
||||
wsUrl: "",
|
||||
host: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current port number as a string
|
||||
*/
|
||||
export function getPort(): string {
|
||||
return getPortConfig().port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for API calls (includes protocol and host)
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
const config = getPortConfig();
|
||||
|
||||
if (config.isDevelopment) {
|
||||
return `${config.baseUrl}/api`;
|
||||
} else {
|
||||
// Production mode: use relative URL for API calls
|
||||
return "/api";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket URL for real-time connections
|
||||
*/
|
||||
export function getWebSocketUrl(path: string = ""): string {
|
||||
const config = getPortConfig();
|
||||
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
||||
|
||||
return `${config.wsUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full base URL (for example code snippets)
|
||||
*/
|
||||
export function getExampleBaseUrl(): string {
|
||||
return getPortConfig().baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host (hostname:port) for example code
|
||||
*/
|
||||
export function getExampleHost(): string {
|
||||
return getPortConfig().host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're in development mode
|
||||
*/
|
||||
export function isDevelopmentMode(): boolean {
|
||||
return getPortConfig().isDevelopment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete URL for a specific endpoint
|
||||
*/
|
||||
export function getEndpointUrl(endpoint: string): string {
|
||||
const config = getPortConfig();
|
||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
|
||||
if (config.isDevelopment) {
|
||||
return `${config.baseUrl}${cleanEndpoint}`;
|
||||
} else {
|
||||
// Production mode: use relative URLs
|
||||
return cleanEndpoint;
|
||||
}
|
||||
}
|
||||
167
ui/lib/utils/routingRules.ts
Normal file
167
ui/lib/utils/routingRules.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Routing Rules Utility Functions
|
||||
* Helper functions for CEL validation, formatting, and rule management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates if a CEL expression has basic correct syntax
|
||||
* @param expression - The CEL expression to validate
|
||||
* @returns true if expression appears syntactically valid
|
||||
*/
|
||||
export function isValidCELExpression(expression: string): boolean {
|
||||
if (!expression) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimmed = expression.trim();
|
||||
if (trimmed.length === 0 || trimmed === "true" || trimmed === "false") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for basic syntax issues
|
||||
if (trimmed.includes(";;")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for matching brackets/parentheses
|
||||
const openBrackets = (trimmed.match(/[[{]/g) || []).length;
|
||||
const closeBrackets = (trimmed.match(/[\]}]/g) || []).length;
|
||||
const openParens = (trimmed.match(/\(/g) || []).length;
|
||||
const closeParens = (trimmed.match(/\)/g) || []).length;
|
||||
|
||||
if (openBrackets !== closeBrackets || openParens !== closeParens) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a fallback string (provider/model) for display
|
||||
* @param fallback - The fallback string (e.g., "openai/gpt-4o")
|
||||
* @returns Formatted fallback string
|
||||
*/
|
||||
export function formatFallback(fallback: string): string {
|
||||
if (!fallback) return "";
|
||||
const parts = fallback.split("/");
|
||||
return parts.length === 2 ? `${parts[0].toUpperCase()} - ${parts[1]}` : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a fallback string into provider and model
|
||||
* @param fallback - The fallback string (e.g., "openai/gpt-4o")
|
||||
* @returns Object with provider and model, or null if invalid
|
||||
*/
|
||||
export function parseFallback(fallback: string): { provider: string; model: string } | null {
|
||||
if (!fallback) return null;
|
||||
const parts = fallback.split("/");
|
||||
if (parts.length !== 2) return null;
|
||||
return { provider: parts[0], model: parts[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts fallback array to string format for display/editing
|
||||
* @param fallbacks - Array of fallback strings
|
||||
* @returns Comma-separated string
|
||||
*/
|
||||
export function fallbacksToString(fallbacks?: string[]): string {
|
||||
if (!fallbacks || fallbacks.length === 0) return "";
|
||||
return fallbacks.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts comma-separated string to fallback array
|
||||
* @param str - Comma-separated fallback string
|
||||
* @returns Array of fallback strings
|
||||
*/
|
||||
export function stringToFallbacks(str: string): string[] {
|
||||
if (!str || str.trim().length === 0) return [];
|
||||
return str
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a friendly display name for a scope
|
||||
* @param scope - The scope value (global|team|customer|virtual_key)
|
||||
* @returns Friendly display name
|
||||
*/
|
||||
export function getScopeLabel(scope: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
global: "Global",
|
||||
team: "Team",
|
||||
customer: "Customer",
|
||||
virtual_key: "Virtual Key",
|
||||
};
|
||||
return labels[scope] || scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates CEL expression for table display
|
||||
* @param expression - The CEL expression
|
||||
* @param maxLength - Maximum length (default 60)
|
||||
* @returns Truncated expression with ellipsis if needed
|
||||
*/
|
||||
export function truncateCELExpression(expression: string, maxLength: number = 60): string {
|
||||
if (!expression) return "";
|
||||
if (expression.length <= maxLength) return expression;
|
||||
return expression.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a provider/model combination
|
||||
* @param provider - The provider name
|
||||
* @param model - The model name (optional)
|
||||
* @returns Error message if invalid, empty string if valid
|
||||
*/
|
||||
export function validateProviderModel(provider: string, _model?: string): string {
|
||||
if (!provider || provider.trim().length === 0) {
|
||||
return "Provider is required";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a CSS class for priority badge color
|
||||
* @returns CSS class name for styling
|
||||
*/
|
||||
export function getPriorityBadgeClass(): string {
|
||||
return "bg-primary text-primary-foreground";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user-friendly CEL operator from the expression
|
||||
* @param expression - The CEL expression
|
||||
* @returns Array of detected operators
|
||||
*/
|
||||
export function detectCELOperators(expression: string): string[] {
|
||||
const operators: string[] = [];
|
||||
if (!expression) return operators;
|
||||
|
||||
// Common CEL operators
|
||||
const operatorPatterns = [
|
||||
{ regex: /==/, label: "Equals" },
|
||||
{ regex: /!=/, label: "Not equals" },
|
||||
{ regex: />=/, label: "Greater than or equal" },
|
||||
{ regex: /<=/, label: "Less than or equal" },
|
||||
{ regex: />/, label: "Greater than" },
|
||||
{ regex: /</, label: "Less than" },
|
||||
{ regex: /&&/, label: "AND" },
|
||||
{ regex: /\|\|/, label: "OR" },
|
||||
{ regex: /!(?!=)/, label: "NOT" },
|
||||
{ regex: /in\s/, label: "IN" },
|
||||
{ regex: /.matches\(/, label: "Regex" },
|
||||
{ regex: /.startsWith\(/, label: "StartsWith" },
|
||||
{ regex: /.contains\(/, label: "Contains" },
|
||||
{ regex: /.endsWith\(/, label: "EndsWith" },
|
||||
];
|
||||
|
||||
operatorPatterns.forEach(({ regex, label }) => {
|
||||
if (regex.test(expression) && !operators.includes(label)) {
|
||||
operators.push(label);
|
||||
}
|
||||
});
|
||||
|
||||
return operators;
|
||||
}
|
||||
204
ui/lib/utils/strings.test.ts
Normal file
204
ui/lib/utils/strings.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { cleanNumericInput } from "./strings";
|
||||
|
||||
// Simulate what onChange does: clean → Number()
|
||||
function simulateOnChange(raw: string): { display: string; value: number | undefined } {
|
||||
const cleaned = cleanNumericInput(raw);
|
||||
if (cleaned === "" || cleaned === ".") {
|
||||
return { display: cleaned, value: undefined };
|
||||
}
|
||||
const n = Number(cleaned);
|
||||
return { display: cleaned, value: isNaN(n) ? undefined : n };
|
||||
}
|
||||
|
||||
// Simulate what onBlur does: normalize display string
|
||||
function simulateOnBlur(displayValue: string): { display: string; value: number | undefined } {
|
||||
const trimmed = displayValue.trim();
|
||||
if (trimmed === "" || trimmed === ".") {
|
||||
return { display: "", value: undefined };
|
||||
}
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num)) {
|
||||
return { display: String(num), value: num };
|
||||
}
|
||||
return { display: "", value: undefined };
|
||||
}
|
||||
|
||||
describe("cleanNumericInput", () => {
|
||||
// Basic valid inputs
|
||||
test("empty string", () => expect(cleanNumericInput("")).toBe(""));
|
||||
test("single digit", () => expect(cleanNumericInput("5")).toBe("5"));
|
||||
test("multiple digits", () => expect(cleanNumericInput("123")).toBe("123"));
|
||||
test("decimal number", () => expect(cleanNumericInput("1.5")).toBe("1.5"));
|
||||
test("leading decimal", () => expect(cleanNumericInput(".5")).toBe(".5"));
|
||||
test("trailing decimal", () => expect(cleanNumericInput("5.")).toBe("5."));
|
||||
test("zero", () => expect(cleanNumericInput("0")).toBe("0"));
|
||||
test("decimal zero", () => expect(cleanNumericInput("0.0")).toBe("0.0"));
|
||||
test("long decimal", () => expect(cleanNumericInput("123.456")).toBe("123.456"));
|
||||
|
||||
// Comma-separated (thousands)
|
||||
test("1,000", () => expect(cleanNumericInput("1,000")).toBe("1000"));
|
||||
test("1,000,000", () => expect(cleanNumericInput("1,000,000")).toBe("1000000"));
|
||||
test("1,234.56", () => expect(cleanNumericInput("1,234.56")).toBe("1234.56"));
|
||||
|
||||
// Underscore-separated (programming style)
|
||||
test("1_000", () => expect(cleanNumericInput("1_000")).toBe("1000"));
|
||||
test("1_000_000", () => expect(cleanNumericInput("1_000_000")).toBe("1000000"));
|
||||
|
||||
// Space-separated
|
||||
test("1 000", () => expect(cleanNumericInput("1 000")).toBe("1000"));
|
||||
test("1 000 000", () => expect(cleanNumericInput("1 000 000")).toBe("1000000"));
|
||||
|
||||
// Alphabetic characters — should stop
|
||||
test("1abc", () => expect(cleanNumericInput("1abc")).toBe("1"));
|
||||
test("123abc456", () => expect(cleanNumericInput("123abc456")).toBe("123"));
|
||||
test("abc123", () => expect(cleanNumericInput("abc123")).toBe(""));
|
||||
|
||||
// Multiple consecutive separators — should stop
|
||||
test("1,,000", () => expect(cleanNumericInput("1,,000")).toBe("1"));
|
||||
test("1..5", () => expect(cleanNumericInput("1..5")).toBe("1.5"));
|
||||
test("1 000", () => expect(cleanNumericInput("1 000")).toBe("1"));
|
||||
|
||||
// Multiple decimal points — second dot treated as separator
|
||||
test("12.3.4", () => expect(cleanNumericInput("12.3.4")).toBe("12.34"));
|
||||
test("1.2.3.4", () => expect(cleanNumericInput("1.2.3.4")).toBe("1.234"));
|
||||
|
||||
// Currency symbols and special chars
|
||||
test("$100", () => expect(cleanNumericInput("$100")).toBe("100"));
|
||||
test("€1,000", () => expect(cleanNumericInput("€1,000")).toBe("1000"));
|
||||
test("100%", () => expect(cleanNumericInput("100%")).toBe("100"));
|
||||
|
||||
// Trailing separator with no digit after
|
||||
test("100,", () => expect(cleanNumericInput("100,")).toBe("100"));
|
||||
test("100.", () => expect(cleanNumericInput("100.")).toBe("100."));
|
||||
|
||||
// Just a dot
|
||||
test(".", () => expect(cleanNumericInput(".")).toBe("."));
|
||||
|
||||
// Negative sign (non-alpha, stripped as separator if digit follows)
|
||||
test("-5", () => expect(cleanNumericInput("-5")).toBe("5"));
|
||||
test("-1,000", () => expect(cleanNumericInput("-1,000")).toBe("1000"));
|
||||
|
||||
// Whitespace
|
||||
test(" 123 ", () => expect(cleanNumericInput(" 123 ")).toBe("123"));
|
||||
test("1 0 0", () => expect(cleanNumericInput("1 0 0")).toBe("100"));
|
||||
test(" 1,000 ", () => expect(cleanNumericInput(" 1,000 ")).toBe("1000"));
|
||||
test(" .5 ", () => expect(cleanNumericInput(" .5 ")).toBe(".5"));
|
||||
|
||||
// Tab and mixed whitespace
|
||||
test("tab 123", () => expect(cleanNumericInput("\t123\t")).toBe("123"));
|
||||
test("newline 123", () => expect(cleanNumericInput("\n123\n")).toBe("123"));
|
||||
|
||||
// Plus sign
|
||||
test("+5", () => expect(cleanNumericInput("+5")).toBe("5"));
|
||||
test("+1,000", () => expect(cleanNumericInput("+1,000")).toBe("1000"));
|
||||
|
||||
// Mixed separators
|
||||
test("1_000,000.50", () => expect(cleanNumericInput("1_000,000.50")).toBe("1000000.50"));
|
||||
|
||||
// Parentheses (accounting negative)
|
||||
test("(100)", () => expect(cleanNumericInput("(100)")).toBe("100"));
|
||||
|
||||
// Only separators
|
||||
test(",", () => expect(cleanNumericInput(",")).toBe(""));
|
||||
test(",,", () => expect(cleanNumericInput(",,")).toBe(""));
|
||||
test("_", () => expect(cleanNumericInput("_")).toBe(""));
|
||||
|
||||
// Only alpha
|
||||
test("abc", () => expect(cleanNumericInput("abc")).toBe(""));
|
||||
test("NaN", () => expect(cleanNumericInput("NaN")).toBe(""));
|
||||
test("Infinity", () => expect(cleanNumericInput("Infinity")).toBe(""));
|
||||
|
||||
// Very large numbers
|
||||
test("999,999,999.99", () => expect(cleanNumericInput("999,999,999.99")).toBe("999999999.99"));
|
||||
test("1_000_000_000", () => expect(cleanNumericInput("1_000_000_000")).toBe("1000000000"));
|
||||
|
||||
// Pasted from spreadsheet with trailing whitespace/newline
|
||||
test("1234\\n", () => expect(cleanNumericInput("1234\n")).toBe("1234"));
|
||||
test("\\t5000\\t", () => expect(cleanNumericInput("\t5000\t")).toBe("5000"));
|
||||
|
||||
// Unicode non-breaking space
|
||||
test("1\u00A0000", () => expect(cleanNumericInput("1\u00A0000")).toBe("1000"));
|
||||
|
||||
// Hash, slash, other symbols before digits
|
||||
test("#100", () => expect(cleanNumericInput("#100")).toBe("100"));
|
||||
test("/100", () => expect(cleanNumericInput("/100")).toBe("100"));
|
||||
|
||||
// Zero edge cases
|
||||
test("0.00", () => expect(cleanNumericInput("0.00")).toBe("0.00"));
|
||||
test("00.5", () => expect(cleanNumericInput("00.5")).toBe("00.5"));
|
||||
test("000", () => expect(cleanNumericInput("000")).toBe("000"));
|
||||
test("0,000", () => expect(cleanNumericInput("0,000")).toBe("0000"));
|
||||
});
|
||||
|
||||
describe("simulateOnChange (clean + Number)", () => {
|
||||
test("empty → undefined", () => {
|
||||
expect(simulateOnChange("")).toEqual({ display: "", value: undefined });
|
||||
});
|
||||
test("just dot → undefined", () => {
|
||||
expect(simulateOnChange(".")).toEqual({ display: ".", value: undefined });
|
||||
});
|
||||
test("123 → 123", () => {
|
||||
expect(simulateOnChange("123")).toEqual({ display: "123", value: 123 });
|
||||
});
|
||||
test("1.5 → 1.5", () => {
|
||||
expect(simulateOnChange("1.5")).toEqual({ display: "1.5", value: 1.5 });
|
||||
});
|
||||
test("0.0 → display 0.0, value 0", () => {
|
||||
expect(simulateOnChange("0.0")).toEqual({ display: "0.0", value: 0 });
|
||||
});
|
||||
test("1,000 → 1000", () => {
|
||||
expect(simulateOnChange("1,000")).toEqual({ display: "1000", value: 1000 });
|
||||
});
|
||||
test("1_000 → 1000", () => {
|
||||
expect(simulateOnChange("1_000")).toEqual({ display: "1000", value: 1000 });
|
||||
});
|
||||
test("1abc → 1", () => {
|
||||
expect(simulateOnChange("1abc")).toEqual({ display: "1", value: 1 });
|
||||
});
|
||||
test("$100 → 100", () => {
|
||||
expect(simulateOnChange("$100")).toEqual({ display: "100", value: 100 });
|
||||
});
|
||||
test("1,234.56 → 1234.56", () => {
|
||||
expect(simulateOnChange("1,234.56")).toEqual({ display: "1234.56", value: 1234.56 });
|
||||
});
|
||||
test(" 500 → 500 (trimmed)", () => {
|
||||
expect(simulateOnChange(" 500 ")).toEqual({ display: "500", value: 500 });
|
||||
});
|
||||
test("abc → empty, undefined", () => {
|
||||
expect(simulateOnChange("abc")).toEqual({ display: "", value: undefined });
|
||||
});
|
||||
test("999,999,999.99 → 999999999.99", () => {
|
||||
expect(simulateOnChange("999,999,999.99")).toEqual({ display: "999999999.99", value: 999999999.99 });
|
||||
});
|
||||
test("0.01 → 0.01", () => {
|
||||
expect(simulateOnChange("0.01")).toEqual({ display: "0.01", value: 0.01 });
|
||||
});
|
||||
test("-5 → 5 (negative sign stripped)", () => {
|
||||
expect(simulateOnChange("-5")).toEqual({ display: "5", value: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("simulateOnBlur (normalize display)", () => {
|
||||
test("empty → empty, undefined", () => {
|
||||
expect(simulateOnBlur("")).toEqual({ display: "", value: undefined });
|
||||
});
|
||||
test("dot → empty, undefined", () => {
|
||||
expect(simulateOnBlur(".")).toEqual({ display: "", value: undefined });
|
||||
});
|
||||
test("0. → 0", () => {
|
||||
expect(simulateOnBlur("0.")).toEqual({ display: "0", value: 0 });
|
||||
});
|
||||
test("1.0 → 1 (Number normalizes trailing zero)", () => {
|
||||
expect(simulateOnBlur("1.0")).toEqual({ display: "1", value: 1 });
|
||||
});
|
||||
test("1.50 → 1.5", () => {
|
||||
expect(simulateOnBlur("1.50")).toEqual({ display: "1.5", value: 1.5 });
|
||||
});
|
||||
test("0100 → 100 (leading zero stripped)", () => {
|
||||
expect(simulateOnBlur("0100")).toEqual({ display: "100", value: 100 });
|
||||
});
|
||||
test("1000 → 1000", () => {
|
||||
expect(simulateOnBlur("1000")).toEqual({ display: "1000", value: 1000 });
|
||||
});
|
||||
});
|
||||
45
ui/lib/utils/strings.ts
Normal file
45
ui/lib/utils/strings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export function capitalize(name: string) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
// Cleans raw input into a valid numeric string:
|
||||
// - Single non-alphabetic separator between digits (commas, spaces, underscores) → stripped
|
||||
// - Alphabetic characters → stop processing
|
||||
// - 2+ consecutive non-digit characters → stop processing
|
||||
// - First decimal point preserved, subsequent dots stripped
|
||||
export function cleanNumericInput(raw: string): string {
|
||||
raw = raw.trim();
|
||||
let result = "";
|
||||
let hasDecimal = false;
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i];
|
||||
if (/\d/.test(ch)) {
|
||||
result += ch;
|
||||
i++;
|
||||
} else if (ch === "." && !hasDecimal) {
|
||||
result += ch;
|
||||
hasDecimal = true;
|
||||
i++;
|
||||
} else if (/[a-zA-Z]/.test(ch)) {
|
||||
break;
|
||||
} else {
|
||||
// Non-alphabetic, non-digit character (comma, space, extra dot, etc.)
|
||||
// Accept only if it's a single separator followed by a digit
|
||||
if (i + 1 < raw.length && /\d/.test(raw[i + 1])) {
|
||||
i++; // skip the separator
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
44
ui/lib/utils/timeRange.ts
Normal file
44
ui/lib/utils/timeRange.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const TIME_PERIODS = [
|
||||
{ label: "Last hour", value: "1h" },
|
||||
{ label: "Last 6 hours", value: "6h" },
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
];
|
||||
|
||||
export type TimePeriod = (typeof TIME_PERIODS)[number]["value"];
|
||||
|
||||
/** Returns a fresh { from, to } Date pair for the given relative period string. */
|
||||
export function getRangeForPeriod(period: string): { from: Date; to: Date } {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime());
|
||||
switch (period) {
|
||||
case "1h":
|
||||
from.setHours(from.getHours() - 1);
|
||||
break;
|
||||
case "6h":
|
||||
from.setHours(from.getHours() - 6);
|
||||
break;
|
||||
case "24h":
|
||||
from.setHours(from.getHours() - 24);
|
||||
break;
|
||||
case "7d":
|
||||
from.setDate(from.getDate() - 7);
|
||||
break;
|
||||
case "30d":
|
||||
from.setDate(from.getDate() - 30);
|
||||
break;
|
||||
default:
|
||||
from.setHours(from.getHours() - 1);
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/** Returns unix timestamps (seconds) for the given relative period string. */
|
||||
export function getUnixRangeForPeriod(period: string): { start: number; end: number } {
|
||||
const { from, to } = getRangeForPeriod(period);
|
||||
return {
|
||||
start: Math.floor(from.getTime() / 1000),
|
||||
end: Math.floor(to.getTime() / 1000),
|
||||
};
|
||||
}
|
||||
592
ui/lib/utils/validation.ts
Normal file
592
ui/lib/utils/validation.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import { PROVIDER_SUPPORTED_REQUESTS } from "../constants/config";
|
||||
import { BaseProvider } from "../types/config";
|
||||
|
||||
export interface ValidationRule {
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationConfig {
|
||||
rules: ValidationRule[];
|
||||
showAlways?: boolean; // If true, shows tooltip even when field is untouched
|
||||
}
|
||||
|
||||
export interface FieldValidation {
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
showTooltip: boolean;
|
||||
}
|
||||
|
||||
export const validateField = (value: any, config: ValidationConfig, touched: boolean): FieldValidation => {
|
||||
const invalidRule = config.rules.find((rule) => !rule.isValid);
|
||||
|
||||
return {
|
||||
isValid: !invalidRule,
|
||||
message: invalidRule?.message || "",
|
||||
showTooltip: config.showAlways || (touched && !!invalidRule),
|
||||
};
|
||||
};
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validateForm = (rules: ValidationRule[]): ValidationResult => {
|
||||
const invalidRules = rules.filter((rule) => !rule.isValid);
|
||||
return {
|
||||
isValid: invalidRules.length === 0,
|
||||
errors: invalidRules.map((rule) => rule.message),
|
||||
};
|
||||
};
|
||||
|
||||
export class Validator {
|
||||
private rules: ValidationRule[];
|
||||
|
||||
constructor(rules: ValidationRule[]) {
|
||||
this.rules = rules.filter((rule) => rule !== undefined);
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return !this.rules.some((rule) => !rule.isValid);
|
||||
}
|
||||
|
||||
getErrors(): string[] {
|
||||
return this.rules.filter((rule) => !rule.isValid).map((rule) => rule.message);
|
||||
}
|
||||
|
||||
getFirstError(): string | undefined {
|
||||
const firstInvalidRule = this.rules.find((rule) => !rule.isValid);
|
||||
return firstInvalidRule?.message;
|
||||
}
|
||||
|
||||
// Built-in validators
|
||||
static required(value: any, message = "This field is required"): ValidationRule {
|
||||
return {
|
||||
isValid: value !== undefined && value !== null && value !== "" && value !== 0,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static minValue(value: number, min: number, message = `Must be at least ${min}`): ValidationRule {
|
||||
return {
|
||||
isValid: !isNaN(value) && value >= min,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static maxValue(value: number, max: number, message = `Must be at most ${max}`): ValidationRule {
|
||||
return {
|
||||
isValid: !isNaN(value) && value <= max,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static pattern(value: string, regex: RegExp, message: string): ValidationRule {
|
||||
return {
|
||||
isValid: regex.test(value || ""),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static email(value: string, message = "Must be a valid email"): ValidationRule {
|
||||
return this.pattern(value, /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message);
|
||||
}
|
||||
|
||||
static url(value: string, message = "Must be a valid URL"): ValidationRule {
|
||||
return this.pattern(value, /^https?:\/\/.+/, message);
|
||||
}
|
||||
|
||||
static minLength(value: string, min: number, message = `Must be at least ${min} characters`): ValidationRule {
|
||||
return {
|
||||
isValid: (value || "").length >= min,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static maxLength(value: string, max: number, message = `Must be at most ${max} characters`): ValidationRule {
|
||||
return {
|
||||
isValid: (value || "").length <= max,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arrayMinLength<T>(array: T[], min: number, message = `Must have at least ${min} items`): ValidationRule {
|
||||
return {
|
||||
isValid: array?.length >= min,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arrayMaxLength<T>(array: T[], max: number, message = `Must have at most ${max} items`): ValidationRule {
|
||||
return {
|
||||
isValid: array?.length <= max,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arrayUnique<T>(array: T[], message = "Must have unique items"): ValidationRule {
|
||||
return {
|
||||
isValid: array?.length === new Set(array).size,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arraysEqual<T>(array1: T[], array2: T[], message = "Must be equal"): ValidationRule {
|
||||
return {
|
||||
isValid: array1?.length === array2?.length && array1?.every((value, index) => value === array2[index]),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static custom(isValid: boolean, message: string): ValidationRule {
|
||||
return {
|
||||
isValid,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Combine multiple validation rules
|
||||
static all(rules: ValidationRule[]): ValidationRule {
|
||||
const invalidRule = rules.find((rule) => !rule.isValid);
|
||||
return invalidRule || { isValid: true, message: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions for validation and redaction detection
|
||||
|
||||
/**
|
||||
* Checks if a value is redacted based on the backend redaction patterns
|
||||
* @param value - The value to check
|
||||
* @returns true if the value is redacted
|
||||
*/
|
||||
export function isRedacted(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's an environment variable reference
|
||||
if (value.startsWith("env.")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for exact redaction pattern: 4 chars + 24 asterisks + 4 chars (total 32)
|
||||
if (value.length === 32) {
|
||||
const middle = value.substring(4, 28);
|
||||
if (middle === "*".repeat(24)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for short key redaction (all asterisks, length <= 8)
|
||||
if (value.length <= 8 && /^\*+$/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSON string is valid
|
||||
* @param value - The JSON string to validate
|
||||
* @returns true if valid JSON
|
||||
*/
|
||||
export function isValidJSON(value: string): boolean {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Vertex auth credentials
|
||||
* @param value - The auth credentials value
|
||||
* @returns true if valid (redacted, env var, or valid service account JSON)
|
||||
*/
|
||||
export function isValidVertexAuthCredentials(value: string): boolean {
|
||||
if (!value || !value.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If redacted, consider it valid (backend has the real value)
|
||||
if (isRedacted(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If environment variable, validate format
|
||||
if (value.startsWith("env.")) {
|
||||
return value.length > 4;
|
||||
}
|
||||
|
||||
// Try to parse as service account JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && parsed.type === "service_account" && parsed.project_id && parsed.private_key;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates aliases configuration
|
||||
* @param value - The aliases value (object or string)
|
||||
* @returns true if valid (redacted, or valid JSON object)
|
||||
*/
|
||||
export function isValidAliases(value: Record<string, string> | string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's already an object, check if it has entries
|
||||
if (typeof value === "object") {
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
// If it's a string, check for redaction or valid JSON
|
||||
if (typeof value === "string") {
|
||||
// If redacted, consider it valid (backend has the real value)
|
||||
if (isRedacted(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid origin URL or wildcard pattern
|
||||
* @param origin - The origin URL to validate (supports wildcards like https://*.example.com)
|
||||
* @returns true if valid origin (protocol + hostname + optional port) or valid wildcard pattern
|
||||
*/
|
||||
export function isValidOrigin(origin: string): boolean {
|
||||
if (!origin || !origin.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow just "*" to mean allow everything
|
||||
if (origin.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
if (origin.includes("*")) {
|
||||
return isValidWildcardOrigin(origin);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
|
||||
// Must have protocol and hostname
|
||||
if (!url.protocol || !url.hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be http or https
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not have path, query, or fragment (origin should be just protocol + hostname + port)
|
||||
if (url.pathname !== "/" || url.search || url.hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid wildcard origin pattern
|
||||
* @param origin - The wildcard origin pattern to validate
|
||||
* @returns true if valid wildcard pattern
|
||||
*/
|
||||
function isValidWildcardOrigin(origin: string): boolean {
|
||||
// Basic validation: must start with protocol
|
||||
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the part after protocol
|
||||
const protocolEnd = origin.indexOf("://") + 3;
|
||||
const hostPart = origin.substring(protocolEnd);
|
||||
|
||||
// Must not have path, query, or fragment
|
||||
if (hostPart.includes("/") || hostPart.includes("?") || hostPart.includes("#")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle port if present
|
||||
let hostname = hostPart;
|
||||
if (hostPart.includes(":")) {
|
||||
const parts = hostPart.split(":");
|
||||
if (parts.length !== 2) return false;
|
||||
hostname = parts[0];
|
||||
const port = parts[1];
|
||||
// Validate port is a number
|
||||
if (!/^\d+$/.test(port) || parseInt(port) < 1 || parseInt(port) > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate wildcard patterns
|
||||
// Only allow wildcards at the beginning of subdomains
|
||||
if (hostname === "*") {
|
||||
return true; // Allow just * for any domain
|
||||
}
|
||||
|
||||
// Pattern like *.example.com
|
||||
if (hostname.startsWith("*.")) {
|
||||
const domain = hostname.substring(2);
|
||||
// Domain part after *. must be valid
|
||||
if (!domain || domain.includes("*") || domain.startsWith(".") || domain.endsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
// Basic domain validation - must have at least one dot and valid characters
|
||||
return /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain);
|
||||
}
|
||||
|
||||
// No other wildcard patterns are allowed
|
||||
if (hostname.includes("*")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an array of origin URLs
|
||||
* @param origins - Array of origin URLs to validate
|
||||
* @returns Object with validation result and invalid origins
|
||||
*/
|
||||
export function validateOrigins(origins: string[]): { isValid: boolean; invalidOrigins: string[] } {
|
||||
if (!origins || origins.length === 0) {
|
||||
return { isValid: true, invalidOrigins: [] };
|
||||
}
|
||||
|
||||
const invalidOrigins = origins.filter((origin) => !isValidOrigin(origin));
|
||||
|
||||
return {
|
||||
isValid: invalidOrigins.length === 0,
|
||||
invalidOrigins,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid Redis address
|
||||
* Supports formats:
|
||||
* - host:port (IPv4)
|
||||
* - [host]:port (IPv6)
|
||||
* - redis://host:port
|
||||
* - rediss://host:port
|
||||
* @param addr - The Redis address to validate
|
||||
* @returns true if valid Redis address
|
||||
*/
|
||||
export function isValidRedisAddress(addr: string): boolean {
|
||||
if (!addr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trim input once before processing
|
||||
const trimmedAddr = addr.trim();
|
||||
if (!trimmedAddr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle URL schemes (redis:// or rediss://)
|
||||
if (trimmedAddr.startsWith("redis://") || trimmedAddr.startsWith("rediss://")) {
|
||||
try {
|
||||
const url = new URL(trimmedAddr);
|
||||
const host = url.hostname;
|
||||
const port = url.port || "6379"; // Default Redis port
|
||||
|
||||
// Check if host is IPv6 (contains colons or is bracketed)
|
||||
const isIPv6Host = host.includes(":") || host.startsWith("[");
|
||||
const hostToValidate = isIPv6Host ? host.replace(/^\[|\]$/g, "") : host;
|
||||
|
||||
const isValidHostResult = isIPv6Host ? isValidIPv6(hostToValidate) : isValidHost(hostToValidate);
|
||||
return isValidHostResult && isValidPort(port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IPv6 addresses in brackets [host]:port
|
||||
const ipv6Match = trimmedAddr.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||
if (ipv6Match) {
|
||||
const [, host, port] = ipv6Match;
|
||||
return isValidIPv6(host) && isValidPort(port);
|
||||
}
|
||||
|
||||
// Handle standard host:port format
|
||||
const colonIndex = trimmedAddr.lastIndexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = trimmedAddr.substring(0, colonIndex);
|
||||
const port = trimmedAddr.substring(colonIndex + 1);
|
||||
|
||||
// Validate both host and port
|
||||
return isValidHost(host) && isValidPort(port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid host (hostname or IP address)
|
||||
* @param host - The host to validate
|
||||
* @returns true if valid host
|
||||
*/
|
||||
function isValidHost(host: string): boolean {
|
||||
if (!host || !host.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedHost = host.trim();
|
||||
|
||||
// Check if this looks like an IPv6 address (contains colons or is bracketed)
|
||||
if (trimmedHost.includes(":") || trimmedHost.startsWith("[")) {
|
||||
// Strip brackets if present and validate as IPv6
|
||||
const ipv6Host = trimmedHost.replace(/^\[|\]$/g, "");
|
||||
return isValidIPv6(ipv6Host);
|
||||
}
|
||||
|
||||
// Check for valid hostname/IPv4 patterns
|
||||
// Allow alphanumeric characters, dots, hyphens, and underscores
|
||||
const hostPattern = /^[a-zA-Z0-9._-]+$/;
|
||||
return hostPattern.test(trimmedHost) && trimmedHost.length <= 253;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid port number (strict digit-only validation)
|
||||
* @param port - The port to validate
|
||||
* @returns true if valid port
|
||||
*/
|
||||
function isValidPort(port: string): boolean {
|
||||
if (!port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedPort = port.trim();
|
||||
|
||||
// Port must consist only of digits (no trailing characters like "6379abc")
|
||||
if (!/^\d+$/.test(trimmedPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert to number and check range
|
||||
const portNum = Number(trimmedPort);
|
||||
return portNum >= 1 && portNum <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid IPv6 address
|
||||
* @param host - The IPv6 address to validate (without brackets)
|
||||
* @returns true if valid IPv6 address
|
||||
*/
|
||||
function isValidIPv6(host: string): boolean {
|
||||
if (!host || !host.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedHost = host.trim();
|
||||
|
||||
// Basic IPv6 pattern validation
|
||||
// IPv6 addresses contain colons and hexadecimal characters
|
||||
const ipv6Pattern =
|
||||
/^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$|^::$|^::1$|^([0-9a-fA-F]{0,4}:){0,6}::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$/;
|
||||
|
||||
// Check basic pattern
|
||||
if (!ipv6Pattern.test(trimmedHost)) {
|
||||
// Also allow IPv6 with embedded IPv4 (e.g., ::ffff:192.168.1.1)
|
||||
const ipv6WithIpv4Pattern = /^([0-9a-fA-F]{0,4}:){1,6}(\d{1,3}\.){3}\d{1,3}$|^::([0-9a-fA-F]{0,4}:){0,5}(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!ipv6WithIpv4Pattern.test(trimmedHost)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation: check for valid hex groups and proper structure
|
||||
const parts = trimmedHost.split(":");
|
||||
|
||||
// IPv6 should not have more than 8 groups (unless it's compressed with ::)
|
||||
if (parts.length > 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid hexadecimal groups
|
||||
for (const part of parts) {
|
||||
if (part !== "" && !/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||
// Allow IPv4 dotted notation in the last part
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const isJson = (text: string) => {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanJson = (text: unknown) => {
|
||||
try {
|
||||
if (typeof text === "string") return JSON.parse(text); // parse JSON strings
|
||||
if (Array.isArray(text)) return text; // keep arrays as-is
|
||||
if (text !== null && typeof text === "object") return text; // keep objects as-is
|
||||
if (typeof text === "number" || typeof text === "boolean") return text;
|
||||
return "Invalid payload";
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a request type is disabled for a provider
|
||||
* @param providerType - The provider type
|
||||
* @param requestType - The request type
|
||||
* @returns true if the request type is disabled
|
||||
*/
|
||||
export function isRequestTypeDisabled(providerType: BaseProvider | undefined, requestType: string): boolean {
|
||||
if (!providerType) return false;
|
||||
|
||||
const supportedRequests = PROVIDER_SUPPORTED_REQUESTS[providerType];
|
||||
if (!supportedRequests) return false; // If provider not in base list, allow all
|
||||
|
||||
return !supportedRequests.includes(requestType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans the path overrides by removing empty values
|
||||
* @param overrides - The path overrides to clean
|
||||
* @returns The cleaned path overrides
|
||||
*/
|
||||
export function cleanPathOverrides(overrides?: Record<string, string | undefined>) {
|
||||
if (!overrides) return undefined;
|
||||
|
||||
const entries = Object.entries(overrides)
|
||||
.map(([k, v]) => [k, v?.trim()])
|
||||
.filter(([, v]) => v && v !== "");
|
||||
|
||||
return entries.length ? (Object.fromEntries(entries) as Record<string, string>) : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user