@@ -105,6 +105,7 @@ import { getImpersonationId } from "~/services/impersonation.server";
105105import { logger } from "~/services/logger.server" ;
106106import { getResizableSnapshot } from "~/services/resizablePanel.server" ;
107107import { requireUserId } from "~/services/session.server" ;
108+ import { rbac } from "~/services/rbac.server" ;
108109import { cn } from "~/utils/cn" ;
109110import { lerp } from "~/utils/lerp" ;
110111import {
@@ -190,7 +191,10 @@ async function getRunsListFromTableState({
190191 return null ;
191192 }
192193
193- const clickhouse = await clickhouseFactory . getClickhouseForOrganization ( project . organizationId , "standard" ) ;
194+ const clickhouse = await clickhouseFactory . getClickhouseForOrganization (
195+ project . organizationId ,
196+ "standard"
197+ ) ;
194198 const runsListPresenter = new NextRunListPresenter ( $replica , clickhouse ) ;
195199 const currentPageResult = await runsListPresenter . call ( project . organizationId , environment . id , {
196200 userId,
@@ -254,6 +258,15 @@ async function getRunsListFromTableState({
254258 }
255259}
256260
261+ // Display-only write:runs flags for the Replay/Cancel controls. The cancel
262+ // and replay action routes enforce write:runs independently; this mirrors the
263+ // result so the buttons disable for roles that lack it. Permissive in OSS.
264+ async function runWritePermissions ( request : Request , userId : string , organizationId : string ) {
265+ const auth = await rbac . authenticateSession ( request , { userId, organizationId } ) ;
266+ const canWriteRun = auth . ok ? auth . ability . can ( "write" , { type : "runs" } ) : true ;
267+ return { canReplayRun : canWriteRun , canCancelRun : canWriteRun } ;
268+ }
269+
257270export const loader = async ( { request, params } : LoaderFunctionArgs ) => {
258271 const userId = await requireUserId ( request ) ;
259272 const impersonationId = await getImpersonationId ( request ) ;
@@ -319,11 +332,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
319332 // Skip on `_data` requests (Remix data fetches): they're
320333 // client-driven follow-ups and the client URL is what matters,
321334 // not the loader's view of it.
322- if (
323- ! url . searchParams . has ( "span" ) &&
324- ! url . searchParams . has ( "_data" ) &&
325- buffered . run . spanId
326- ) {
335+ if ( ! url . searchParams . has ( "span" ) && ! url . searchParams . has ( "_data" ) && buffered . run . spanId ) {
327336 url . searchParams . set ( "span" , buffered . run . spanId ) ;
328337 throw redirect ( url . pathname + "?" + url . searchParams . toString ( ) ) ;
329338 }
@@ -337,6 +346,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
337346 maximumLiveReloadingSetting : env . MAXIMUM_LIVE_RELOADING_EVENTS ,
338347 resizable : { parent, tree } ,
339348 runsList : null ,
349+ ...( await runWritePermissions ( request , userId , buffered . run . environment . organizationId ) ) ,
340350 } ) ;
341351 }
342352
@@ -348,11 +358,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
348358 // block in the buffered fallback above — the sibling redirect routes
349359 // do this, but direct navigation to the canonical project-scoped URL
350360 // never hits them, leaving the right detail panel collapsed.
351- if (
352- ! url . searchParams . has ( "span" ) &&
353- ! url . searchParams . has ( "_data" ) &&
354- result . run . spanId
355- ) {
361+ if ( ! url . searchParams . has ( "span" ) && ! url . searchParams . has ( "_data" ) && result . run . spanId ) {
356362 url . searchParams . set ( "span" , result . run . spanId ) ;
357363 throw redirect ( url . pathname + "?" + url . searchParams . toString ( ) ) ;
358364 }
@@ -379,6 +385,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
379385 tree,
380386 } ,
381387 runsList,
388+ ...( await runWritePermissions ( request , userId , result . run . environment . organizationId ) ) ,
382389 } ) ;
383390} ;
384391
@@ -418,8 +425,15 @@ async function tryMollifiedRunFallback(args: {
418425type LoaderData = SerializeFrom < typeof loader > ;
419426
420427export default function Page ( ) {
421- const { run, trace, maximumLiveReloadingSetting, runsList, resizable } =
422- useLoaderData < typeof loader > ( ) ;
428+ const {
429+ run,
430+ trace,
431+ maximumLiveReloadingSetting,
432+ runsList,
433+ resizable,
434+ canReplayRun,
435+ canCancelRun,
436+ } = useLoaderData < typeof loader > ( ) ;
423437 const organization = useOrganization ( ) ;
424438 const project = useProject ( ) ;
425439 const environment = useEnvironment ( ) ;
@@ -501,6 +515,8 @@ export default function Page() {
501515 LeadingIcon = { ArrowUturnLeftIcon }
502516 shortcut = { { key : "R" } }
503517 className = "pr-2"
518+ disabled = { ! canReplayRun }
519+ tooltip = { canReplayRun ? undefined : "You don't have permission to replay runs" }
504520 >
505521 Replay run
506522 </ Button >
@@ -519,6 +535,7 @@ export default function Page() {
519535 { run . isFinished ? null : (
520536 < ControlledCancelRunDialog
521537 key = { `cancel-${ run . friendlyId } ` }
538+ canCancel = { canCancelRun }
522539 runFriendlyId = { run . friendlyId }
523540 redirectPath = { v3RunSpanPath (
524541 organization ,
@@ -617,7 +634,8 @@ function TraceView({
617634 ? linkedRunIdBySpanId ?. [ selectedSpanId ]
618635 : undefined ;
619636 const frozenLinkedRunId = useFrozenValue ( selectedSpanLinkedRunId ) ;
620- const displayLinkedRunId = ( selectedSpanId ? selectedSpanLinkedRunId : frozenLinkedRunId ) ?? undefined ;
637+ const displayLinkedRunId =
638+ ( selectedSpanId ? selectedSpanLinkedRunId : frozenLinkedRunId ) ?? undefined ;
621639
622640 return (
623641 < div className = { cn ( "grid h-full max-h-full grid-cols-1 overflow-hidden" ) } >
@@ -699,15 +717,23 @@ function TraceView({
699717function ControlledCancelRunDialog ( {
700718 runFriendlyId,
701719 redirectPath,
720+ canCancel,
702721} : {
703722 runFriendlyId : string ;
704723 redirectPath : string ;
724+ canCancel : boolean ;
705725} ) {
706726 const [ open , setOpen ] = useState ( false ) ;
707727 return (
708728 < Dialog open = { open } onOpenChange = { setOpen } >
709729 < DialogTrigger asChild >
710- < Button variant = "danger/small" LeadingIcon = { StopCircleIcon } shortcut = { { key : "C" } } >
730+ < Button
731+ variant = "danger/small"
732+ LeadingIcon = { StopCircleIcon }
733+ shortcut = { { key : "C" } }
734+ disabled = { ! canCancel }
735+ tooltip = { canCancel ? undefined : "You don't have permission to cancel runs" }
736+ >
711737 Cancel run…
712738 </ Button >
713739 </ DialogTrigger >
0 commit comments