Skip to content

Commit aca5d24

Browse files
committed
fix(usage): correct chart clipping, expand truncated rows, add source mix
Two rendering defects on the organization usage panel, plus the follow-on cleanup they surfaced. The y-axis maximum was clipped at the container's left edge. The chart family used a fixed 26px left gutter, which leaves 18px of drawable width once the label gap is taken out — four narrow glyphs — so every tick from `7.3k` up lost its first character. Both charts now derive the gutter from the labels they are about to draw, through one shared `resolveChartPadding` so a bar and a line chart still line up when stacked. Hovering near the foot of the plot raised a vertical scrollbar over the chart. The scroll container sets `overflow-x`, which promotes `overflow-y` to `auto`, and the tooltip's vertical clamp was a fixed inset that ignored the box's real height. The clamp now measures the tooltip, and the container pins `overflow-y`. Also on the panel: - The axis rules were invisible. `hsl(var(--border))` is unparseable — the token is a hex — so the presentation attribute was dropped and SVG's initial `stroke: none` applied. - GLM rendered without a mark: the settings provider-icon map held 11 of the registry's 24 providers. Completed, with a test that fails when the two drift. The server's parallel label map was the same 11-entry duplicate and now reads the registry directly. - `Other (N more)` opens the tail in place, raising the row limit to the API's ceiling. Fixed the drill-down name lookup this exposed, which was pinned to the top ten and so refused to open for any row below it. - A radar chart of the source mix sits beside the Sources list. The rows rank the sources; they cannot show whether spend is concentrated or spread. - "Open logs" pointed at `/workspace/<id>/logs` for a workspace picked from an organization-wide list. Organization admin is not workspace membership, so for any workspace the admin had not joined it was a one-way trip to an access denial. It now opens the organization's audit feed scoped to that workspace, which required threading the workspace filter the query builder already supported through the internal contract, route, hook, and URL state. - BYOK is withheld from the tab strip until the ledger carries BYOK rows. - The chip number field suppresses the native stepper, which painted browser chrome inside a flat chip surface. The component owns it, not its callers. - `ChartColumn` was a uniform 0.86 downscale of its source art, so it rendered ~2px small beside every other icon in the settings nav.
1 parent b01b41f commit aca5d24

32 files changed

Lines changed: 1501 additions & 429 deletions

apps/sim/app/api/audit-logs/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const GET = defineInternalJsonRoute({
2727
action: query.action,
2828
resourceType: query.resourceType,
2929
actorId: query.actorId,
30+
workspaceId: query.workspaceId,
3031
startDate: query.startDate,
3132
endDate: query.endDate,
3233
},

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
176176
<AuditLogs organizationId={organizationId} />
177177
)}
178178
{effectiveSection === 'usage' && organizationId && (
179-
<UsageMonitoring organizationId={organizationId} workspaceId={hostContext.workspace.id} />
179+
<UsageMonitoring
180+
organizationId={organizationId}
181+
eventsHref={`/workspace/${hostContext.workspace.id}/settings/usage/events`}
182+
auditLogsHref={`/workspace/${hostContext.workspace.id}/settings/audit-logs`}
183+
/>
180184
)}
181185
{effectiveSection === 'apikeys' && <ApiKeys scope='combined' />}
182186
{isBillingEnabled && effectiveSection === 'billing' && (

apps/sim/app/workspace/[workspaceId]/settings/usage/events/loading.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default function UsageEventsLoading() {
1616
onSelect: () => router.push(`/workspace/${workspaceId}/settings/usage`),
1717
}}
1818
title='Usage events'
19-
description='Every credit-consuming event behind your usage.'
19+
description="Every credit-consuming event across your organization's workspaces."
2020
/>
2121
)
2222
}

apps/sim/components/charts/bar-chart.tsx

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,21 @@ import {
88
formatChartTimestamp,
99
} from '@/components/charts/chart-format'
1010
import {
11+
CHART_AXIS_LABEL_GAP,
1112
CHART_DEFAULT_HEIGHT,
1213
CHART_GRID_FRACTIONS,
13-
CHART_PADDING,
1414
CHART_TICK_FILL,
1515
CHART_TICK_FONT_SIZE,
1616
chartPlotBand,
1717
formatTimeTick,
18+
resolveChartPadding,
1819
resolveSpanMs,
1920
resolveTimeTickIndices,
2021
} from '@/components/charts/chart-geometry'
2122
import {
2223
ChartTooltip,
2324
ChartTooltipRow,
25+
estimateTooltipHeight,
2426
estimateTooltipWidth,
2527
positionChartTooltip,
2628
} from '@/components/charts/chart-tooltip'
@@ -47,6 +49,17 @@ interface BarChartProps {
4749
highlightIndex?: number
4850
}
4951

52+
/** Tick and tooltip text for a bucket's value, in the caller's unit. */
53+
function formatBarValue(value: number | undefined, unit: string | undefined): string {
54+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
55+
const suffix = (unit ?? '').toLowerCase()
56+
if (suffix.includes('%')) return `${value.toFixed(1)}%`
57+
if (suffix === 'latency') return formatChartLatency(value)
58+
if (suffix.includes('ms')) return `${Math.round(value)}ms`
59+
if (suffix === 'credits') return formatChartCompactNumber(value)
60+
return `${Math.round(value)}${unit ?? ''}`
61+
}
62+
5063
/**
5164
* Discrete time buckets as bars.
5265
*
@@ -71,16 +84,11 @@ function BarChartComponent({
7184
const uniqueId = useId().replace(/:/g, '')
7285
const [containerRef, containerWidth] = useChartWidth()
7386
const width = containerWidth ?? 0
74-
const padding = CHART_PADDING
75-
const chartWidth = width - padding.left - padding.right
76-
const chartHeight = height - padding.top - padding.bottom
7787
const { yMin, yMax } = chartPlotBand(height)
7888
const isDark = useIsDarkTheme()
79-
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
8089
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
8190

82-
const colorTokens = useMemo(() => ({ base: color }), [color])
83-
const resolvedColors = useResolvedChartColors(colorTokens)
91+
const resolvedColors = useResolvedChartColors({ base: color })
8492
const resolvedColor = resolvedColors.base || color
8593

8694
const hasExternalWrapper = !label
@@ -100,10 +108,27 @@ function BarChartComponent({
100108
return peak <= 0 ? 1 : peak * 1.1
101109
}, [data])
102110

111+
const padding = resolveChartPadding([formatBarValue(maxValue, unit), '0'])
112+
const chartWidth = width - padding.left - padding.right
113+
const chartHeight = height - padding.top - padding.bottom
114+
103115
/** Slot geometry: every bucket owns an equal slice, with the bar centred in it. */
104116
const slot = data.length > 0 ? Math.max(1, chartWidth) / data.length : 0
105117
const barWidth = Math.max(1, Math.min(24, slot * 0.7))
106118

119+
/**
120+
* The hovered bucket, derived from the stored cursor rather than stored beside it.
121+
*
122+
* Bars own a slot, so this is which slot the cursor is in — not the nearest sample,
123+
* which is how a line chart resolves it. Deriving it means a resize mid-hover cannot
124+
* leave an index that disagrees with the slot geometry it was measured against, and
125+
* a mouse move writes one state instead of two.
126+
*/
127+
const hoverIndex =
128+
hoverPos === null || data.length === 0 || slot <= 0
129+
? null
130+
: Math.max(0, Math.min(data.length - 1, Math.floor((hoverPos.x - padding.left) / slot)))
131+
107132
const bars = useMemo(
108133
() =>
109134
data.map((point, index) => {
@@ -127,21 +152,14 @@ function BarChartComponent({
127152
[data, slot, barWidth, maxValue, chartHeight, height, padding.left, padding.top, yMin, yMax]
128153
)
129154

130-
const formatValue = (value?: number) => {
131-
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
132-
const suffix = (unit ?? '').toLowerCase()
133-
if (suffix.includes('%')) return `${value.toFixed(1)}%`
134-
if (suffix === 'latency') return formatChartLatency(value)
135-
if (suffix.includes('ms')) return `${Math.round(value)}ms`
136-
if (suffix === 'credits') return formatChartCompactNumber(value)
137-
return `${Math.round(value)}${unit ?? ''}`
138-
}
139-
140155
if (containerWidth === null) {
141156
return (
142157
<div
143158
ref={containerRef}
144-
className={cn('w-full', !hasExternalWrapper && 'rounded-lg border bg-card p-4')}
159+
className={cn(
160+
'w-full',
161+
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
162+
)}
145163
style={{ height }}
146164
/>
147165
)
@@ -156,7 +174,7 @@ function BarChartComponent({
156174
ref={containerRef}
157175
className={cn(
158176
'flex w-full items-center justify-center',
159-
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
177+
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
160178
)}
161179
/*
162180
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
@@ -185,8 +203,14 @@ function BarChartComponent({
185203
contradicted the constant's own note that the chart "scrolls rather than
186204
compresses". At or above the floor there is no overflow and nothing changes.
187205
*/
188-
'w-full overflow-x-auto',
189-
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
206+
/*
207+
`overflow-y-hidden` is not redundant with `overflow-x-auto`: a computed
208+
`overflow-x` other than `visible` promotes `overflow-y: visible` to `auto`,
209+
so the tooltip's shadow reaching the foot of the box raised a vertical
210+
scrollbar over the chart whenever the cursor neared the axis.
211+
*/
212+
'w-full overflow-x-auto overflow-y-hidden',
213+
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4 shadow-card'
190214
)}
191215
>
192216
{!hasExternalWrapper && (
@@ -202,20 +226,9 @@ function BarChartComponent({
202226
onMouseMove={(e) => {
203227
if (bars.length === 0 || slot <= 0) return
204228
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
205-
const x = e.clientX - rect.left
206-
// Bars own a slot, so the hovered bucket is which slot the cursor is in —
207-
// not the nearest sample, which is how a line chart resolves it.
208-
const index = Math.max(
209-
0,
210-
Math.min(data.length - 1, Math.floor((x - padding.left) / slot))
211-
)
212-
setHoverIndex(index)
213-
setHoverPos({ x, y: e.clientY - rect.top })
214-
}}
215-
onMouseLeave={() => {
216-
setHoverIndex(null)
217-
setHoverPos(null)
229+
setHoverPos({ x: e.clientX - rect.left, y: e.clientY - rect.top })
218230
}}
231+
onMouseLeave={() => setHoverPos(null)}
219232
>
220233
<defs>
221234
<linearGradient id={`bar-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
@@ -229,7 +242,7 @@ function BarChartComponent({
229242
y1={padding.top}
230243
x2={padding.left}
231244
y2={height - padding.bottom}
232-
stroke='hsl(var(--border))'
245+
stroke='var(--border)'
233246
strokeWidth='1'
234247
/>
235248

@@ -240,7 +253,7 @@ function BarChartComponent({
240253
y1={padding.top + chartHeight * fraction}
241254
x2={width - padding.right}
242255
y2={padding.top + chartHeight * fraction}
243-
stroke='hsl(var(--muted))'
256+
stroke='var(--border)'
244257
strokeOpacity='0.35'
245258
strokeWidth='1'
246259
/>
@@ -313,18 +326,18 @@ function BarChartComponent({
313326
})}
314327

315328
<text
316-
x={padding.left - 8}
329+
x={padding.left - CHART_AXIS_LABEL_GAP}
317330
y={padding.top}
318331
textAnchor='end'
319332
fontSize={CHART_TICK_FONT_SIZE}
320333
fill={CHART_TICK_FILL}
321334
>
322335
{/* Same formatter the tooltip uses, or the axis and the hover disagree
323336
about what the numbers mean on any non-`credits` unit. */}
324-
{formatValue(maxValue)}
337+
{formatBarValue(maxValue, unit)}
325338
</text>
326339
<text
327-
x={padding.left - 8}
340+
x={padding.left - CHART_AXIS_LABEL_GAP}
328341
y={height - padding.bottom}
329342
textAnchor='end'
330343
fontSize={CHART_TICK_FONT_SIZE}
@@ -338,7 +351,7 @@ function BarChartComponent({
338351
y1={height - padding.bottom}
339352
x2={width - padding.right}
340353
y2={height - padding.bottom}
341-
stroke='hsl(var(--border))'
354+
stroke='var(--border)'
342355
strokeWidth='1'
343356
/>
344357
</svg>
@@ -347,20 +360,19 @@ function BarChartComponent({
347360
bars[hoverIndex] &&
348361
(() => {
349362
const bar = bars[hoverIndex]
350-
const value = formatValue(bar.point.value)
363+
const value = formatBarValue(bar.point.value, unit)
364+
const date = formatChartTimestamp(bar.point.timestamp)
351365
const { left, top } = positionChartTooltip({
352366
anchorX: hoverPos?.x ?? bar.x,
353367
anchorY: hoverPos?.y ?? bar.y,
354368
width,
355369
height,
356370
tooltipMaxWidth: estimateTooltipWidth(value.length),
371+
tooltipHeight: estimateTooltipHeight(1, Boolean(date)),
372+
padding,
357373
})
358374
return (
359-
<ChartTooltip
360-
left={left}
361-
top={top}
362-
date={formatChartTimestamp(bar.point.timestamp) || undefined}
363-
>
375+
<ChartTooltip left={left} top={top} date={date || undefined}>
364376
<ChartTooltipRow color={resolvedColor} value={value} />
365377
</ChartTooltip>
366378
)

apps/sim/components/charts/chart-geometry.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
CHART_AXIS_LABEL_GAP,
67
CHART_PADDING,
78
chartPlotBand,
9+
estimateAxisLabelWidth,
810
formatTimeTick,
11+
resolveChartPadding,
912
resolveSpanMs,
1013
resolveTimeTickIndices,
1114
} from '@/components/charts/chart-geometry'
@@ -78,3 +81,33 @@ describe('chartPlotBand', () => {
7881
expect(chartPlotBand(240).yMax).toBeGreaterThan(chartPlotBand(166).yMax)
7982
})
8083
})
84+
85+
describe('resolveChartPadding', () => {
86+
/**
87+
* The bug this exists for: a fixed 26px gutter left 18px of drawable width once
88+
* `CHART_AXIS_LABEL_GAP` was taken out, and a right-anchored `7.3k` at 9px is wider
89+
* than that — so its first glyph was cut off at the container's left edge.
90+
*/
91+
it('widens the gutter until the longest label fits beside the axis', () => {
92+
const { left } = resolveChartPadding(['7.3k', '0'])
93+
expect(left).toBeGreaterThanOrEqual(estimateAxisLabelWidth('7.3k') + CHART_AXIS_LABEL_GAP)
94+
})
95+
96+
it('never narrows below the shared padding, so short labels stay aligned', () => {
97+
expect(resolveChartPadding(['0', '0']).left).toBe(CHART_PADDING.left)
98+
expect(resolveChartPadding([]).left).toBe(CHART_PADDING.left)
99+
})
100+
101+
it('leaves the other three sides on the shared constant', () => {
102+
const padding = resolveChartPadding(['123.4m'])
103+
expect(padding.top).toBe(CHART_PADDING.top)
104+
expect(padding.right).toBe(CHART_PADDING.right)
105+
expect(padding.bottom).toBe(CHART_PADDING.bottom)
106+
})
107+
108+
it('grows monotonically with label length', () => {
109+
const short = resolveChartPadding(['1.2k']).left
110+
const long = resolveChartPadding(['123456.7m']).left
111+
expect(long).toBeGreaterThan(short)
112+
})
113+
})

apps/sim/components/charts/chart-geometry.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,56 @@
1010

1111
export const CHART_PADDING = { top: 16, right: 28, bottom: 26, left: 26 } as const
1212

13+
export type ChartPadding = { top: number; right: number; bottom: number; left: number }
14+
1315
/** Matches the loader placeholders callers size themselves against. */
1416
export const CHART_DEFAULT_HEIGHT = 166
1517

1618
/** Below this the axis labels collide, so the chart scrolls rather than compresses. */
1719
export const CHART_MIN_WIDTH = 280
1820

1921
export const CHART_TICK_FILL = 'var(--text-tertiary)'
20-
export const CHART_TICK_FONT_SIZE = '9'
22+
export const CHART_TICK_FONT_SIZE = 9
2123
export const CHART_GRID_FRACTIONS = [0.25, 0.5, 0.75] as const
2224

25+
/** Gap between a y-axis tick label's right edge and the axis rule. */
26+
export const CHART_AXIS_LABEL_GAP = 8
27+
28+
/**
29+
* Rendered width of a right-anchored y-axis tick label.
30+
*
31+
* SVG `<text>` cannot be measured before layout, so the gutter that has to hold it
32+
* is estimated from the glyphs instead. The ratios are for the UI sans at
33+
* {@link CHART_TICK_FONT_SIZE}: digits and letters sit near 0.58em, punctuation and
34+
* spaces near 0.3em. Deliberately generous — an over-wide gutter costs a couple of
35+
* plot pixels, an under-wide one clips the label against the container's edge.
36+
*/
37+
export function estimateAxisLabelWidth(text: string): number {
38+
let width = 0
39+
for (const character of text) {
40+
width += /[.,:\s]/.test(character) ? 0.3 : 0.58
41+
}
42+
return width * CHART_TICK_FONT_SIZE
43+
}
44+
45+
/**
46+
* {@link CHART_PADDING} with a left gutter wide enough for the chart's own y-axis
47+
* labels.
48+
*
49+
* The fixed 26px gutter left 18px of drawable width once the label gap is taken out,
50+
* which fits four narrow glyphs — so any tick past `7.3k` was cut off at the left edge
51+
* of the container. Both charts resolve their gutter through this one function from
52+
* the labels they are about to draw, so a bar and a line chart showing comparable
53+
* magnitudes still line up when stacked in one card, and neither can clip.
54+
*/
55+
export function resolveChartPadding(yAxisLabels: readonly string[]): ChartPadding {
56+
const widest = yAxisLabels.reduce((max, label) => Math.max(max, estimateAxisLabelWidth(label)), 0)
57+
return {
58+
...CHART_PADDING,
59+
left: Math.max(CHART_PADDING.left, Math.ceil(widest) + CHART_AXIS_LABEL_GAP),
60+
}
61+
}
62+
2363
/** Vertical clamp for plotted geometry, keeping strokes off the axis rules. */
2464
export function chartPlotBand(height: number): { yMin: number; yMax: number } {
2565
const chartHeight = height - CHART_PADDING.top - CHART_PADDING.bottom

0 commit comments

Comments
 (0)