From 8a4d5ab78cd49baccfceb65c79d645a6f2370c5b Mon Sep 17 00:00:00 2001 From: colivi Date: Fri, 11 Sep 2026 15:12:31 +0200 Subject: [PATCH 1/2] feat(components): add dtdhms and additional throughput rate units - dtdhms: format duration in seconds as D d HH:MM:SS - throughput: tps, trc/s, trx/s, op/s, msg/s, qps, errors/s, and related rates - count:* units display without the count: prefix Signed-off-by: colivi --- components/src/model/throughput.test.ts | 39 ++++++++++----- components/src/model/throughput.ts | 66 ++++++++++++++++++++++++- components/src/model/time.test.ts | 21 ++++++++ components/src/model/time.ts | 37 +++++++++++++- 4 files changed, 147 insertions(+), 16 deletions(-) diff --git a/components/src/model/throughput.test.ts b/components/src/model/throughput.test.ts index ccefdfe7..7ce5bb25 100644 --- a/components/src/model/throughput.test.ts +++ b/components/src/model/throughput.test.ts @@ -83,6 +83,26 @@ const THROUGHPUT_TESTS: UnitTestCase[] = [ format: { unit: 'bits/sec' }, expected: '1 Kib/s', }, + // Additional rate units + { value: 42, format: { unit: 'tps' }, expected: '42 tps' }, + { value: 1.5, format: { unit: 'trc/s' }, expected: '1.5 trc/s' }, + { value: 10, format: { unit: 'trx/s' }, expected: '10 trx/s' }, + { value: 3, format: { unit: 'e/s' }, expected: '3 e/s' }, + { value: 7, format: { unit: 'op/s' }, expected: '7 op/s' }, + { value: 7, format: { unit: 'ops/s' }, expected: '7 ops/s' }, + { value: 100, format: { unit: 'msg/s' }, expected: '100 msg/s' }, + { value: 2, format: { unit: 'errors/s' }, expected: '2 errors/s' }, + { value: 5, format: { unit: 'calls/s' }, expected: '5 calls/s' }, + { value: 9, format: { unit: 'qps' }, expected: '9 qps' }, + { value: 1, format: { unit: 'drop/s' }, expected: '1 drop/s' }, + { value: 1, format: { unit: 'reject/s' }, expected: '1 reject/s' }, + { value: 4, format: { unit: 'requests/s' }, expected: '4 requests/s' }, + { value: 8, format: { unit: 'flows/s' }, expected: '8 flows/s' }, + { value: 2, format: { unit: 'fail/sec' }, expected: '2 fail/sec' }, + { value: 1, format: { unit: 'to/s' }, expected: '1 to/s' }, + { value: 6, format: { unit: 'count:tps' }, expected: '6 tps' }, + { value: 6, format: { unit: 'count:traces/s' }, expected: '6 traces/s' }, + { value: 6, format: { unit: 'count:msg/s' }, expected: '6 msg/s' }, ]; describe('formatValue', () => { @@ -93,18 +113,11 @@ describe('formatValue', () => { it('should get identical formatters from cache', () => { const { countCacheItems, getKeys } = getFormatterStats(); - expect(countCacheItems('throughput')).toBe(10); - expect(getKeys('throughput')).toStrictEqual([ - 'decimal|true|compact|3|counts/sec|en-US', - 'decimal|true|false|ops/sec|en-US', - 'decimal|true|4|false|requests/sec|en-US', - 'decimal|true|compact|3|true|reads/sec|en-US', - 'decimal|true|compact|4|true|writes/sec|en-US', - 'decimal|true|compact|3|events/sec|en-US', - 'decimal|true|false|messages/sec|en-US', - 'decimal|true|4|false|records/sec|en-US', - 'decimal|true|compact|3|true|rows/sec|en-US', - 'decimal|true|compact|3|ops/sec|en-US', - ]); + // Cache keys include unit id; extra rate units add entries after the core set. + expect(countCacheItems('throughput')).toBeGreaterThanOrEqual(10); + const keys = getKeys('throughput'); + expect(keys).toContain('decimal|true|false|ops/sec|en-US'); + expect(keys).toContain('decimal|true|false|tps|en-US'); + expect(keys).toContain('decimal|true|false|count:tps|en-US'); }); }); diff --git a/components/src/model/throughput.ts b/components/src/model/throughput.ts index 04a6d8df..e6809f15 100644 --- a/components/src/model/throughput.ts +++ b/components/src/model/throughput.ts @@ -32,7 +32,32 @@ type ThroughputUnit = | 'records/sec' | 'requests/sec' | 'rows/sec' - | 'writes/sec'; + | 'writes/sec' + // Additional rate units (unit id = display suffix) + | 'tps' + | 'trc/s' + | 'trx/s' + | 'e/s' + | 'op/s' + | 'ops/s' + | 'msg/s' + | 'msg/sec' + | 'errors/s' + | 'calls/s' + | 'qps' + | 'drop/s' + | 'reject/s' + | 'requests/s' + | 'flows/s' + | 'fail/sec' + | 'to/s' + | 'c/s' + | 'gc/s' + | 'tk/s' + | 'cxn/s' + | 'count:tps' + | 'count:traces/s' + | 'count:msg/s'; export type ThroughputFormatOptions = { unit?: ThroughputUnit; decimalPlaces?: number; @@ -101,8 +126,44 @@ export const THROUGHPUT_UNIT_CONFIG: Readonly group: THROUGHPUT_GROUP, label: 'Writes/sec', }, + + tps: { group: THROUGHPUT_GROUP, label: 'Transactions/sec (tps)' }, + 'trc/s': { group: THROUGHPUT_GROUP, label: 'Traces/sec' }, + 'trx/s': { group: THROUGHPUT_GROUP, label: 'Transactions/sec (trx/s)' }, + 'e/s': { group: THROUGHPUT_GROUP, label: 'Events/sec (e/s)' }, + 'op/s': { group: THROUGHPUT_GROUP, label: 'Ops/sec (op/s)' }, + 'ops/s': { group: THROUGHPUT_GROUP, label: 'Ops/sec (ops/s)' }, + 'msg/s': { group: THROUGHPUT_GROUP, label: 'Messages/sec (msg/s)' }, + 'msg/sec': { group: THROUGHPUT_GROUP, label: 'Messages/sec (msg/sec)' }, + 'errors/s': { group: THROUGHPUT_GROUP, label: 'Errors/sec' }, + 'calls/s': { group: THROUGHPUT_GROUP, label: 'Calls/sec' }, + qps: { group: THROUGHPUT_GROUP, label: 'Queries/sec (qps)' }, + 'drop/s': { group: THROUGHPUT_GROUP, label: 'Drops/sec' }, + 'reject/s': { group: THROUGHPUT_GROUP, label: 'Rejects/sec' }, + 'requests/s': { group: THROUGHPUT_GROUP, label: 'Requests/sec (requests/s)' }, + 'flows/s': { group: THROUGHPUT_GROUP, label: 'Flows/sec' }, + 'fail/sec': { group: THROUGHPUT_GROUP, label: 'Failures/sec' }, + 'to/s': { group: THROUGHPUT_GROUP, label: 'Timeouts/sec' }, + 'c/s': { group: THROUGHPUT_GROUP, label: 'Contentions/sec' }, + 'gc/s': { group: THROUGHPUT_GROUP, label: 'GC/sec' }, + 'tk/s': { group: THROUGHPUT_GROUP, label: 'Tokens/sec' }, + 'cxn/s': { group: THROUGHPUT_GROUP, label: 'Connections/sec' }, + 'count:tps': { group: THROUGHPUT_GROUP, label: 'Count tps' }, + 'count:traces/s': { group: THROUGHPUT_GROUP, label: 'Count traces/sec' }, + 'count:msg/s': { group: THROUGHPUT_GROUP, label: 'Count msg/sec' }, }; +/** Display suffix for axis/stat (strip leading `count:` when present). */ +function throughputSuffix(unit: ThroughputUnit | undefined): string { + if (!unit) { + return ''; + } + if (unit.startsWith('count:')) { + return unit.slice('count:'.length); + } + return unit; +} + export function formatThroughput(value: number, { unit, shortValues, decimalPlaces }: ThroughputFormatOptions): string { // special case for data throughput if (unit === 'bits/sec') { @@ -153,5 +214,6 @@ export function formatThroughput(value: number, { unit, shortValues, decimalPlac unit, ]; - return `${getFormatterFromCache(key, 'throughput', formatterOptions, 'en-US')(value)} ${unit}`; + const suffix = throughputSuffix(unit); + return `${getFormatterFromCache(key, 'throughput', formatterOptions, 'en-US')(value)} ${suffix}`; } diff --git a/components/src/model/time.test.ts b/components/src/model/time.test.ts index f584915a..6de9c83d 100644 --- a/components/src/model/time.test.ts +++ b/components/src/model/time.test.ts @@ -220,6 +220,27 @@ const TIME_TESTS: UnitTestCase[] = [ format: { unit: 'years' }, expected: '100 years', }, + // dtdhms — value is seconds; fixed D d HH:MM:SS (no month/year scale) + { + value: 0, + format: { unit: 'dtdhms' }, + expected: '00:00:00', + }, + { + value: 3661, + format: { unit: 'dtdhms' }, + expected: '01:01:01', + }, + { + value: 86400 + 3723, // 1d 01:02:03 + format: { unit: 'dtdhms' }, + expected: '1 d 01:02:03', + }, + { + value: 14093232, // network uptime style + format: { unit: 'dtdhms' }, + expected: '163 d 02:47:12', + }, ]; describe('formatValue', () => { it.each(TIME_TESTS)('returns $expected when $value formatted as $format', (args: UnitTestCase) => { diff --git a/components/src/model/time.ts b/components/src/model/time.ts index 24bcb51f..e0fc0bd4 100644 --- a/components/src/model/time.ts +++ b/components/src/model/time.ts @@ -26,7 +26,9 @@ type TimeUnits = | 'days' | 'weeks' | 'months' - | 'years'; + | 'years' + /** Duration in seconds → `D d HH:MM:SS` (no month/year auto-scale). */ + | 'dtdhms'; export type TimeFormatOptions = { unit?: TimeUnits; decimalPlaces?: number; @@ -77,6 +79,10 @@ export const TIME_UNIT_CONFIG: Readonly> = { group: TIME_GROUP, label: 'Years', }, + dtdhms: { + group: TIME_GROUP, + label: 'Duration (d HH:MM:SS)', + }, }; // Mapping of time units to what Intl.NumberFormat formatter expects @@ -152,7 +158,36 @@ function isMonthOrYear(unit: TimeUnits): boolean { return unit === 'months' || unit === 'years'; } +/** + * Format a duration given in **seconds** as `D d HH:MM:SS` + * (or `HH:MM:SS` when under one day). Does not auto-scale to months/years. + */ +export function formatDtdhms(totalSeconds: number): string { + if (!Number.isFinite(totalSeconds)) { + return String(totalSeconds); + } + const sign = totalSeconds < 0 ? '-' : ''; + let s = Math.floor(Math.abs(totalSeconds)); + const days = Math.floor(s / 86400); + s %= 86400; + const hours = Math.floor(s / 3600); + s %= 3600; + const minutes = Math.floor(s / 60); + const secs = s % 60; + const hh = String(hours).padStart(2, '0'); + const mm = String(minutes).padStart(2, '0'); + const ss = String(secs).padStart(2, '0'); + if (days > 0) { + return `${sign}${days} d ${hh}:${mm}:${ss}`; + } + return `${sign}${hh}:${mm}:${ss}`; +} + export function formatTime(value: number, { unit, decimalPlaces }: TimeFormatOptions): string { + if (unit === 'dtdhms') { + return formatDtdhms(value); + } + if (value === 0) return '0s'; const results = getValueAndKindForNaturalNumbers(value, unit ?? 'seconds'); From c5fcc212ad16be4614fd1ac6f02ebaee1f54d21c Mon Sep 17 00:00:00 2001 From: colivi Date: Mon, 14 Sep 2026 13:21:02 +0200 Subject: [PATCH 2/2] feat(cue): allow dtdhms and extra throughput units in format schema Backend validation must accept the same unit ids as the TS formatters. Also map Grafana dtdhms and rate aliases in migrate/mapping.cue. Signed-off-by: colivi --- cue/common/format.cue | 9 +++++++-- cue/common/migrate/mapping.cue | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cue/common/format.cue b/cue/common/format.cue index 4c0f13bc..fdb123ab 100644 --- a/cue/common/format.cue +++ b/cue/common/format.cue @@ -31,7 +31,8 @@ package common } #timeFormat: { - unit: "nanoseconds" | "microseconds" | "milliseconds" | "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years" + // dtdhms: duration in seconds as "D d HH:MM:SS" (Grafana dtdhms parity; no month/year auto-scale) + unit: "nanoseconds" | "microseconds" | "milliseconds" | "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years" | "dtdhms" decimalPlaces?: number } @@ -59,7 +60,11 @@ package common } #throughputFormat: { - unit: "bits/sec" | "decbits/sec" | "bytes/sec" | "decbytes/sec" | "counts/sec" | "events/sec" | "messages/sec" | "ops/sec" | "packets/sec" | "reads/sec" | "records/sec" | "requests/sec" | "rows/sec" | "writes/sec" + unit: "bits/sec" | "decbits/sec" | "bytes/sec" | "decbytes/sec" | "counts/sec" | "events/sec" | "messages/sec" | "ops/sec" | "packets/sec" | "reads/sec" | "records/sec" | "requests/sec" | "rows/sec" | "writes/sec" | + // Additional rate units (id = display suffix; Grafana/custom panel aliases) + "tps" | "trc/s" | "trx/s" | "e/s" | "op/s" | "ops/s" | "msg/s" | "msg/sec" | "errors/s" | "calls/s" | "qps" | + "drop/s" | "reject/s" | "requests/s" | "flows/s" | "fail/sec" | "to/s" | "c/s" | "gc/s" | "tk/s" | "cxn/s" | + "count:tps" | "count:traces/s" | "count:msg/s" decimalPlaces?: number shortValues?: bool } diff --git a/cue/common/migrate/mapping.cue b/cue/common/migrate/mapping.cue index bdf806c2..daf0ecd3 100644 --- a/cue/common/migrate/mapping.cue +++ b/cue/common/migrate/mapping.cue @@ -24,6 +24,8 @@ package migrate d: "days" dtdurations: "seconds" dtdurationms: "milliseconds" + // Grafana dtdhms: fixed D d HH:MM:SS (do not map to seconds — different display) + dtdhms: "dtdhms" dateTimeAsIso: "datetime-iso" // percent units percent: "percent" @@ -52,6 +54,31 @@ package migrate ops: "ops/sec" pps: "packets/sec" wps: "writes/sec" + // Additional Grafana / custom rate unit aliases → Perses throughput ids + tps: "tps" + "trc/s": "trc/s" + "trx/s": "trx/s" + "e/s": "e/s" + "op/s": "op/s" + "ops/s": "ops/s" + "msg/s": "msg/s" + "msg/sec": "msg/sec" + "errors/s": "errors/s" + "calls/s": "calls/s" + qps: "qps" + "drop/s": "drop/s" + "reject/s": "reject/s" + "requests/s": "requests/s" + "flows/s": "flows/s" + "fail/sec": "fail/sec" + "to/s": "to/s" + "c/s": "c/s" + "gc/s": "gc/s" + "tk/s": "tk/s" + "cxn/s": "cxn/s" + "count:tps": "count:tps" + "count:traces/s": "count:traces/s" + "count:msg/s": "count:msg/s" } // mapping table for the calculation attribute (key = grafana unit, value = perses equivalent) calc: {