Implements Phases 1-8 of the TFTSR implementation plan. Rust backend (Tauri 2.x, src-tauri/): - Multi-provider AI: OpenAI-compatible, Anthropic, Gemini, Mistral, Ollama - PII detection engine: 11 regex patterns with overlap resolution - SQLCipher AES-256 encrypted database with 10 versioned migrations - 28 Tauri IPC commands for triage, analysis, document, and system ops - Ollama: hardware probe, model recommendations, pull/delete with events - RCA and blameless post-mortem Markdown document generators - PDF export via printpdf - Audit log: SHA-256 hash of every external data send - Integration stubs for Confluence, ServiceNow, Azure DevOps (v0.2) Frontend (React 18 + TypeScript + Vite, src/): - 9 pages: full triage workflow NewIssue→LogUpload→Triage→Resolution→RCA→Postmortem→History+Settings - 7 components: ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI primitives - 3 Zustand stores: session, settings (persisted), history - Type-safe tauriCommands.ts matching Rust backend types exactly - 8 IT domain system prompts (Linux, Windows, Network, K8s, DB, Virt, HW, Obs) DevOps: - .woodpecker/test.yml: rustfmt, clippy, cargo test, tsc, vitest on every push - .woodpecker/release.yml: linux/amd64 + linux/arm64 builds, Gogs release upload Verified: - cargo check: zero errors - tsc --noEmit: zero errors - vitest run: 13/13 unit tests passing Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
150 lines
4.4 KiB
JavaScript
150 lines
4.4 KiB
JavaScript
import parseMilliseconds from 'parse-ms';
|
|
|
|
const isZero = value => value === 0 || value === 0n;
|
|
const pluralize = (word, count) => (count === 1 || count === 1n) ? word : `${word}s`;
|
|
|
|
const SECOND_ROUNDING_EPSILON = 0.000_000_1;
|
|
const ONE_DAY_IN_MILLISECONDS = 24n * 60n * 60n * 1000n;
|
|
|
|
export default function prettyMilliseconds(milliseconds, options) {
|
|
const isBigInt = typeof milliseconds === 'bigint';
|
|
if (!isBigInt && !Number.isFinite(milliseconds)) {
|
|
throw new TypeError('Expected a finite number or bigint');
|
|
}
|
|
|
|
options = {...options};
|
|
|
|
const sign = milliseconds < 0 ? '-' : '';
|
|
milliseconds = milliseconds < 0 ? -milliseconds : milliseconds; // Cannot use `Math.abs()` because of BigInt support.
|
|
|
|
if (options.colonNotation) {
|
|
options.compact = false;
|
|
options.formatSubMilliseconds = false;
|
|
options.separateMilliseconds = false;
|
|
options.verbose = false;
|
|
}
|
|
|
|
if (options.compact) {
|
|
options.unitCount = 1;
|
|
options.secondsDecimalDigits = 0;
|
|
options.millisecondsDecimalDigits = 0;
|
|
}
|
|
|
|
let result = [];
|
|
|
|
const floorDecimals = (value, decimalDigits) => {
|
|
const flooredInterimValue = Math.floor((value * (10 ** decimalDigits)) + SECOND_ROUNDING_EPSILON);
|
|
const flooredValue = Math.round(flooredInterimValue) / (10 ** decimalDigits);
|
|
return flooredValue.toFixed(decimalDigits);
|
|
};
|
|
|
|
const add = (value, long, short, valueString) => {
|
|
if (
|
|
(result.length === 0 || !options.colonNotation)
|
|
&& isZero(value)
|
|
&& !(options.colonNotation && short === 'm')) {
|
|
return;
|
|
}
|
|
|
|
valueString ??= String(value);
|
|
if (options.colonNotation) {
|
|
const wholeDigits = valueString.includes('.') ? valueString.split('.')[0].length : valueString.length;
|
|
const minLength = result.length > 0 ? 2 : 1;
|
|
valueString = '0'.repeat(Math.max(0, minLength - wholeDigits)) + valueString;
|
|
} else {
|
|
valueString += options.verbose ? ' ' + pluralize(long, value) : short;
|
|
}
|
|
|
|
result.push(valueString);
|
|
};
|
|
|
|
const parsed = parseMilliseconds(milliseconds);
|
|
const days = BigInt(parsed.days);
|
|
|
|
if (options.hideYearAndDays) {
|
|
add((BigInt(days) * 24n) + BigInt(parsed.hours), 'hour', 'h');
|
|
} else {
|
|
if (options.hideYear) {
|
|
add(days, 'day', 'd');
|
|
} else {
|
|
add(days / 365n, 'year', 'y');
|
|
add(days % 365n, 'day', 'd');
|
|
}
|
|
|
|
add(Number(parsed.hours), 'hour', 'h');
|
|
}
|
|
|
|
add(Number(parsed.minutes), 'minute', 'm');
|
|
|
|
if (!options.hideSeconds) {
|
|
if (
|
|
options.separateMilliseconds
|
|
|| options.formatSubMilliseconds
|
|
|| (!options.colonNotation && milliseconds < 1000 && !options.subSecondsAsDecimals)
|
|
) {
|
|
const seconds = Number(parsed.seconds);
|
|
const milliseconds = Number(parsed.milliseconds);
|
|
const microseconds = Number(parsed.microseconds);
|
|
const nanoseconds = Number(parsed.nanoseconds);
|
|
|
|
add(seconds, 'second', 's');
|
|
|
|
if (options.formatSubMilliseconds) {
|
|
add(milliseconds, 'millisecond', 'ms');
|
|
add(microseconds, 'microsecond', 'µs');
|
|
add(nanoseconds, 'nanosecond', 'ns');
|
|
} else {
|
|
const millisecondsAndBelow
|
|
= milliseconds
|
|
+ (microseconds / 1000)
|
|
+ (nanoseconds / 1e6);
|
|
|
|
const millisecondsDecimalDigits
|
|
= typeof options.millisecondsDecimalDigits === 'number'
|
|
? options.millisecondsDecimalDigits
|
|
: 0;
|
|
|
|
const roundedMilliseconds = millisecondsAndBelow >= 1
|
|
? Math.round(millisecondsAndBelow)
|
|
: Math.ceil(millisecondsAndBelow);
|
|
|
|
const millisecondsString = millisecondsDecimalDigits
|
|
? millisecondsAndBelow.toFixed(millisecondsDecimalDigits)
|
|
: roundedMilliseconds;
|
|
|
|
add(
|
|
Number.parseFloat(millisecondsString),
|
|
'millisecond',
|
|
'ms',
|
|
millisecondsString,
|
|
);
|
|
}
|
|
} else {
|
|
const seconds = (
|
|
(isBigInt ? Number(milliseconds % ONE_DAY_IN_MILLISECONDS) : milliseconds)
|
|
/ 1000
|
|
) % 60;
|
|
const secondsDecimalDigits
|
|
= typeof options.secondsDecimalDigits === 'number'
|
|
? options.secondsDecimalDigits
|
|
: 1;
|
|
const secondsFixed = floorDecimals(seconds, secondsDecimalDigits);
|
|
const secondsString = options.keepDecimalsOnWholeSeconds
|
|
? secondsFixed
|
|
: secondsFixed.replace(/\.0+$/, '');
|
|
add(Number.parseFloat(secondsString), 'second', 's', secondsString);
|
|
}
|
|
}
|
|
|
|
if (result.length === 0) {
|
|
return sign + '0' + (options.verbose ? ' milliseconds' : 'ms');
|
|
}
|
|
|
|
const separator = options.colonNotation ? ':' : ' ';
|
|
if (typeof options.unitCount === 'number') {
|
|
result = result.slice(0, Math.max(options.unitCount, 1));
|
|
}
|
|
|
|
return sign + result.join(separator);
|
|
}
|