Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/sim/app/api/audit-logs/export/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,29 @@ describe('GET /api/audit-logs/export', () => {
expect(response.status).toBe(400)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})

/**
* The export has to filter by everything the on-screen feed does. It did not
* forward `workspaceId`, so an admin exporting from a workspace-scoped feed
* downloaded the whole organization — silently, because every field of
* `AuditLogFilterParams` is optional and dropping one still type-checks.
*/
it('forwards the workspace filter the on-screen feed applies', async () => {
mockGetOrgWorkspaceIds.mockResolvedValue(['workspace-1'])

await GET(makeRequest('?workspaceId=workspace-1'))

expect(mockBuildFilterConditions).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: 'workspace-1' })
)
})

it('rejects a workspaceId outside the organization, as the list route does', async () => {
mockGetOrgWorkspaceIds.mockResolvedValue(['workspace-1'])

const response = await GET(makeRequest('?workspaceId=workspace-elsewhere'))

expect(response.status).toBe(400)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
})
33 changes: 23 additions & 10 deletions apps/sim/app/api/audit-logs/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}

const { organizationId, orgMemberIds } = authResult.context
const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } =
parsed.data.query
const { actorId, workspaceId, includeDeparted } = parsed.data.query

if (actorId && !orgMemberIds.includes(actorId)) {
return NextResponse.json(
Expand All @@ -77,20 +76,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}

const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
/**
* The same refusal `listAuditLogs` gives. The scope predicate already makes an
* out-of-organization id return nothing, but an empty CSV and a 400 that names the
* problem are very different answers to the same bad request, and the two paths
* disagreeing about which one you get is what an audit trail cannot afford.
*/
if (workspaceId && !orgWorkspaceIds.includes(workspaceId)) {
return NextResponse.json(
{ error: 'workspaceId does not belong to your organization' },
{ status: 400 }
)
}
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted,
})
const filterConditions = buildFilterConditions({
action,
resourceType,
actorId,
search,
startDate,
endDate,
})
/**
* The whole parsed query, not a hand-listed subset.
*
* Every field of `AuditLogFilterParams` is optional, so dropping one type-checks
* silently — which is how `workspaceId` came to be accepted by the contract,
* honoured by the list route, and ignored here: an admin looking at one
* workspace's feed downloaded the entire organization's, under a truncation
* warning that blamed the date range.
*/
const filterConditions = buildFilterConditions(parsed.data.query)
const conditions = [scopeCondition, ...filterConditions]

const rows: ReturnType<typeof formatAuditLogEntry>[] = []
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/audit-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const GET = defineInternalJsonRoute({
action: query.action,
resourceType: query.resourceType,
actorId: query.actorId,
workspaceId: query.workspaceId,
startDate: query.startDate,
endDate: query.endDate,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
<AuditLogs organizationId={organizationId} />
)}
{effectiveSection === 'usage' && organizationId && (
<UsageMonitoring organizationId={organizationId} workspaceId={hostContext.workspace.id} />
<UsageMonitoring
organizationId={organizationId}
eventsHref={`/workspace/${hostContext.workspace.id}/settings/usage/events`}
auditLogsHref={`/workspace/${hostContext.workspace.id}/settings/audit-logs`}
/>
)}
{effectiveSection === 'apikeys' && <ApiKeys scope='combined' />}
{isBillingEnabled && effectiveSection === 'billing' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,14 @@ export function UsageLimitField({

return (
<SettingsSection label='Usage limit' headerAccessory={USAGE_LIMIT_INFO}>
{/*
Text with a numeric input mode rather than `type='number'`: the native stepper
is all that type buys and it does not fit the chip chrome. The minimum is
enforced on commit below, where it can explain itself, rather than by a `min`
attribute the browser enforces silently.
*/}
<ChipInput
type='number'
inputMode='numeric'
min={dollarsToCredits(minimumLimit)}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={
Expand All @@ -135,7 +139,6 @@ export function UsageLimitField({
: String(dollarsToCredits(currentLimit))
}
disabled={!canEdit}
inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
/>
</SettingsSection>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,17 @@ export function ManageCreditsModal({
value={isLoading ? 'Loading…' : creditsUsed}
copyLabel='Copy credits used'
/>
{/*
Text with a numeric input mode, not `inputType='number'` — the same choice
the retry settings field documents. The native stepper is all the number
type buys, and it paints browser chrome inside a flat chip surface. It also
reports `''` for anything the browser considers invalid, so a typo arrived
here indistinguishable from a cleared field and saved as "no limit"; as text
it reaches the `Number.isInteger` check below and is refused.
*/}
<ChipModalField
type='input'
inputType='number'
inputMode='numeric'
title={
<span className='inline-flex items-center gap-1.5'>
Credit limit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function UsageEventsLoading() {
onSelect: () => router.push(`/workspace/${workspaceId}/settings/usage`),
}}
title='Usage events'
description='Every credit-consuming event behind your usage.'
description="Every credit-consuming event across your organization's workspaces."
/>
)
}
95 changes: 49 additions & 46 deletions apps/sim/components/charts/bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@ import {
formatChartTimestamp,
} from '@/components/charts/chart-format'
import {
CHART_AXIS_LABEL_GAP,
CHART_DEFAULT_HEIGHT,
CHART_GRID_FRACTIONS,
CHART_PADDING,
CHART_TICK_FILL,
CHART_TICK_FONT_SIZE,
chartPlotBand,
formatTimeTick,
resolveChartPadding,
resolveSpanMs,
resolveTimeTickIndices,
} from '@/components/charts/chart-geometry'
import {
ChartTooltip,
ChartTooltipRow,
estimateTooltipHeight,
estimateTooltipWidth,
positionChartTooltip,
} from '@/components/charts/chart-tooltip'
Expand All @@ -47,6 +49,17 @@ interface BarChartProps {
highlightIndex?: number
}

/** Tick and tooltip text for a bucket's value, in the caller's unit. */
function formatBarValue(value: number | undefined, unit: string | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
const suffix = (unit ?? '').toLowerCase()
if (suffix.includes('%')) return `${value.toFixed(1)}%`
if (suffix === 'latency') return formatChartLatency(value)
if (suffix.includes('ms')) return `${Math.round(value)}ms`
if (suffix === 'credits') return formatChartCompactNumber(value)
return `${Math.round(value)}${unit ?? ''}`
}

/**
* Discrete time buckets as bars.
*
Expand All @@ -71,16 +84,11 @@ function BarChartComponent({
const uniqueId = useId().replace(/:/g, '')
const [containerRef, containerWidth] = useChartWidth()
const width = containerWidth ?? 0
const padding = CHART_PADDING
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const { yMin, yMax } = chartPlotBand(height)
const isDark = useIsDarkTheme()
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)

const colorTokens = useMemo(() => ({ base: color }), [color])
const resolvedColors = useResolvedChartColors(colorTokens)
const resolvedColors = useResolvedChartColors({ base: color })
const resolvedColor = resolvedColors.base || color

const hasExternalWrapper = !label
Expand All @@ -100,10 +108,24 @@ function BarChartComponent({
return peak <= 0 ? 1 : peak * 1.1
}, [data])

const padding = resolveChartPadding([formatBarValue(maxValue, unit), '0'])
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom

/** Slot geometry: every bucket owns an equal slice, with the bar centred in it. */
const slot = data.length > 0 ? Math.max(1, chartWidth) / data.length : 0
const barWidth = Math.max(1, Math.min(24, slot * 0.7))

/**
* Bars own a slot, so the hovered bucket is which slot the cursor is in — not the
* nearest sample, which is how a line chart resolves it. Derived, so a resize
* mid-hover cannot leave an index disagreeing with the slot geometry.
*/
const hoverIndex =
hoverPos === null || data.length === 0 || slot <= 0
? null
: Math.max(0, Math.min(data.length - 1, Math.floor((hoverPos.x - padding.left) / slot)))

const bars = useMemo(
() =>
data.map((point, index) => {
Expand All @@ -127,21 +149,14 @@ function BarChartComponent({
[data, slot, barWidth, maxValue, chartHeight, height, padding.left, padding.top, yMin, yMax]
)

const formatValue = (value?: number) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
const suffix = (unit ?? '').toLowerCase()
if (suffix.includes('%')) return `${value.toFixed(1)}%`
if (suffix === 'latency') return formatChartLatency(value)
if (suffix.includes('ms')) return `${Math.round(value)}ms`
if (suffix === 'credits') return formatChartCompactNumber(value)
return `${Math.round(value)}${unit ?? ''}`
}

if (containerWidth === null) {
return (
<div
ref={containerRef}
className={cn('w-full', !hasExternalWrapper && 'rounded-lg border bg-card p-4')}
className={cn(
'w-full',
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
)}
style={{ height }}
/>
)
Expand All @@ -156,7 +171,7 @@ function BarChartComponent({
ref={containerRef}
className={cn(
'flex w-full items-center justify-center',
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
)}
/*
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
Expand Down Expand Up @@ -185,8 +200,8 @@ function BarChartComponent({
contradicted the constant's own note that the chart "scrolls rather than
compresses". At or above the floor there is no overflow and nothing changes.
*/
'w-full overflow-x-auto',
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
'w-full overflow-x-auto overflow-y-hidden',
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4 shadow-card'
)}
>
{!hasExternalWrapper && (
Expand All @@ -202,20 +217,9 @@ function BarChartComponent({
onMouseMove={(e) => {
if (bars.length === 0 || slot <= 0) return
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
const x = e.clientX - rect.left
// Bars own a slot, so the hovered bucket is which slot the cursor is in —
// not the nearest sample, which is how a line chart resolves it.
const index = Math.max(
0,
Math.min(data.length - 1, Math.floor((x - padding.left) / slot))
)
setHoverIndex(index)
setHoverPos({ x, y: e.clientY - rect.top })
}}
onMouseLeave={() => {
setHoverIndex(null)
setHoverPos(null)
setHoverPos({ x: e.clientX - rect.left, y: e.clientY - rect.top })
}}
onMouseLeave={() => setHoverPos(null)}
>
<defs>
<linearGradient id={`bar-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
Expand All @@ -229,7 +233,7 @@ function BarChartComponent({
y1={padding.top}
x2={padding.left}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
stroke='var(--border)'
strokeWidth='1'
/>

Expand All @@ -240,7 +244,7 @@ function BarChartComponent({
y1={padding.top + chartHeight * fraction}
x2={width - padding.right}
y2={padding.top + chartHeight * fraction}
stroke='hsl(var(--muted))'
stroke='var(--border)'
strokeOpacity='0.35'
strokeWidth='1'
/>
Expand Down Expand Up @@ -313,18 +317,18 @@ function BarChartComponent({
})}

<text
x={padding.left - 8}
x={padding.left - CHART_AXIS_LABEL_GAP}
y={padding.top}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
fill={CHART_TICK_FILL}
>
{/* Same formatter the tooltip uses, or the axis and the hover disagree
about what the numbers mean on any non-`credits` unit. */}
{formatValue(maxValue)}
{formatBarValue(maxValue, unit)}
</text>
<text
x={padding.left - 8}
x={padding.left - CHART_AXIS_LABEL_GAP}
y={height - padding.bottom}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
Expand All @@ -338,7 +342,7 @@ function BarChartComponent({
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
stroke='var(--border)'
strokeWidth='1'
/>
</svg>
Expand All @@ -347,20 +351,19 @@ function BarChartComponent({
bars[hoverIndex] &&
(() => {
const bar = bars[hoverIndex]
const value = formatValue(bar.point.value)
const value = formatBarValue(bar.point.value, unit)
const date = formatChartTimestamp(bar.point.timestamp)
const { left, top } = positionChartTooltip({
anchorX: hoverPos?.x ?? bar.x,
anchorY: hoverPos?.y ?? bar.y,
width,
height,
tooltipMaxWidth: estimateTooltipWidth(value.length),
tooltipHeight: estimateTooltipHeight(1, Boolean(date)),
padding,
})
return (
<ChartTooltip
left={left}
top={top}
date={formatChartTimestamp(bar.point.timestamp) || undefined}
>
<ChartTooltip left={left} top={top} date={date || undefined}>
<ChartTooltipRow color={resolvedColor} value={value} />
</ChartTooltip>
)
Expand Down
Loading
Loading